Overview
Evaluating DS health and native mobile readiness for SwiftUI and Jetpack Compose.
Each component is evaluated against seven criteria. The report is self-contained and versions alongside the design system.
.html file in assessment-src/components/ with the nav, summary, and full assessment.node assessment-src/build.js to compile all component files into this page automatically.The overall status reflects the weakest criterion — one unresolved issue can block Code Connect linkability.
| ID | Criterion | What We Check |
|---|---|---|
| C1 | Layer Structure & Naming | Layers should use semantic names like leading-icon or content — not Figma defaults like Frame 42 or Group 7. The hierarchy should be logical and free of unnecessary nesting. |
| C2 | Variant & Property Naming | Variants and properties should follow clear, consistent conventions. Booleans expressed as true/false (not yes/no), enum values lowercase and hyphenated. |
| C3 | Token Coverage | All color, spacing, typography, and radius values should be bound to design tokens — not hardcoded. Token coverage determines how easily engineers can map decisions to a native token system. |
| C4 | Native Mappability | The component should map cleanly to a standard native primitive (e.g. DisclosureGroup, Button, List) with no web-only patterns that lack a native equivalent. |
| C5 | Interaction State Coverage | All expected interactive states should be defined as variants — default, pressed, focused, disabled, and error. Missing states force engineers to invent visual behavior. |
| C6 | Asset & Icon Quality | Icons should be vector components (not raster or PNG embeds) and colored using tokens so tinting works natively on both platforms. |
| C7 | Code Connect Linkability | The component should be a proper Figma component set with property names clean enough to map 1:1 to native parameters via Code Connect. |
| Status | Meaning |
|---|---|
| Ready | Linkable as-is. Clean structure, maps well to native. |
| Needs Refinement | Minor issues to resolve before linking. |
| Requires Rework | Needs redesign before native translation. |
| Not Applicable | No native equivalent. |
| Fix | Resolved via Figma MCP. Residual items may remain. |
Components
00 components in East Blue Design System
description slot.steps prop and a current prop.steps prop.multiline / lineLimit prop.appearance: .translucent.A disclosure row that expands to reveal content. Supports optional leading icon and description via boolean visibility properties. Reduced from 24 to 6 variants (Type × State) with color tokens fully connected.
How the accordion appears in a real product screen — expanding to reveal content.

Toggle properties to see the accordion update in real time.
content-body slot. Boolean visibility on leadingIcon and description lets designers configure the component without extra variants. content-body panel are both included. Engineers can implement it as a standalone unit with no external spec needed. Type) drives collapsed/expanded. leadingIcon and description are boolean show/hide properties — no duplicate variants needed. icon-leading, content, trailing-icon). Chevrons are vector instances. The icon slot accepts instances cleanly. | State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | State=Default | Header row with chevron. Tap to expand/collapse. |
| Pressed | Yes | Yes | State=Pressed | Visual feedback on touch. Darker surface token. |
| Disabled | Yes | Yes | State=Disabled | Muted colors. Tap ignored. Chevron dimmed. |
| Focused (a11y) | N/A | N/A | — | Mobile OS handles focus rings natively. |
- Boolean properties converted from yes/no to true/false (C2)
- Layer names corrected to semantic naming:
container,icon-leading,content,trailing-icon(C1) - Pressed and disabled interaction states added across all 6 variants (C5)
- Expanded content panel with
content-bodySLOT added to all expanded variants (C4) - Variant set reduced from 24 to 6 —
Type×Statematrix (C2) leadingIconanddescriptionconverted to boolean visibility properties- Fixed 56px header height applied across all 6 variants
- 10 design tokens connected — all colors, spacing, and typography fully tokenized (C3)
- Annotation instance frame built with nested auto layout (Type × State grid)
- Code Connect mappings not registered. No native component files are linked yet. All structural blockers are resolved — registration can now proceed. C7 · Code Connect Linkability
- Add an
AccordionGroupcompound component. Manages exclusive expand (only one open at a time) — the canonical FAQ and settings pattern. Avoids every consumer wiring their own expanded-id state. Family
Header row only — 56px fixed height. Trailing chevron points down. Tap anywhere in the row to expand.
All colors are bound to design tokens from the component variable collection.
| Role | Token | Default | Pressed | Disabled |
|---|---|---|---|---|
| Header bg | surface/default | #FFFFFF | — | — |
| Pressed bg | surface/pressed | — | #F4F7FB | — |
| Disabled bg | surface/disabled | — | — | #F8F9FB |
| Border | border/subtle | #E5EBF4 | #E5EBF4 | #E5EBF4 |
| Label | text/primary | #0A2757 | #0A2757 | — |
| Label (disabled) | text/disabled | — | — | #C2C6CF |
| Description | text/secondary | #90A8D0 | #90A8D0 | — |
| Icon placeholder | icon/placeholder | #C2C6CF | #C2C6CF | #C2C6CF |
| Chevron | icon-chevron | #005CE5 | #005CE5 | #C2CFE5 |
Header row (56px) + content-body panel (56px SLOT)=112px total height. Trailing chevron points up. Content-body background uses surface/content token.
Expanded adds the surface/content token for the content-body panel background.
| Role | Token | Default | Pressed | Disabled |
|---|---|---|---|---|
| Header bg | surface/default | #FFFFFF | — | — |
| Pressed bg | surface/pressed | — | #F4F7FB | — |
| Disabled bg | surface/disabled | — | — | #F8F9FB |
| Content bg | surface/content | #F4F7FB | #F4F7FB | #F8F9FB |
| Border | border/subtle | #E5EBF4 | #E5EBF4 | #E5EBF4 |
| Label | text/primary | #0A2757 | #0A2757 | — |
| Label (disabled) | text/disabled | — | — | #C2C6CF |
| Description | text/secondary | #90A8D0 | #90A8D0 | — |
| Icon placeholder | icon/placeholder | #C2C6CF | #C2C6CF | #C2C6CF |
| Chevron | icon-chevron | #005CE5 | #005CE5 | #C2CFE5 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios" // Or in Package.swift: .package( url: "https://github.com/AY-Org/eb-ds-ios", from: "1.0.0" )
Android — Gradle (Kotlin DSL)
// build.gradle.kts (app) dependencies { implementation("com.eastblue.ds:accordion:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.accordion.* // Compose
Package not yet published. These are the planned distribution paths. API shape is final — native implementation is pending.
Every row maps a Figma component property to its native equivalent.
| Figma Property | SwiftUI | Compose |
|---|---|---|
Type=Collapsed | isExpanded: false | isExpanded=false |
Type=Expanded | isExpanded: true | isExpanded=true |
State=Disabled | .disabled(true) | enabled=false |
leadingIcon=true | leadingIcon: Image? | leadingIcon: @Composable (() -> Unit)? |
description=true | description: String? | description: String? |
Content-Body (SLOT) | content: () -> some View | content: @Composable () -> Unit |
// Basic EBAccordion("Settings", isExpanded: $isExpanded) { Text("Content goes here") } // With leading icon EBAccordion("Settings", isExpanded: $isExpanded, leadingIcon: Image(systemName: "gear") ) { Text("Content") } // With description EBAccordion("Settings", description: "Manage your preferences", isExpanded: $isExpanded ) { Text("Content") } // Disabled EBAccordion("Settings", isExpanded: $isExpanded) { Text("Content") } .disabled(true)
// Basic EBAccordion( title = "Settings", isExpanded = isExpanded, onExpandedChange = { isExpanded = it } ) { Text("Content goes here") } // With leading icon EBAccordion( title = "Settings", isExpanded = isExpanded, onExpandedChange = { isExpanded = it }, leadingIcon = { Icon(Icons.Filled.Settings, null) } ) { Text("Content") } // Disabled EBAccordion( title = "Settings", isExpanded = isExpanded, onExpandedChange = {}, enabled = false ) { Text("Content") }
| Requirement | iOS | Android |
|---|---|---|
| Min touch target | 44 × 44pt (full header row) | 48 × 48dp (full header row) |
| Expand/collapse | .accessibilityAction(.default) toggles | onClick handler on header |
| State announcement | .accessibilityValue("expanded"/"collapsed") | expandedState semantics |
| Disabled | .disabled(true) — announced by VoiceOver | enabled=false |
| Content-body | Automatically read by screen reader when expanded | |
| Chevron icon | .accessibilityHidden(true) — decorative | contentDescription=null — decorative |
Do
Use Accordion for progressive disclosure — hiding secondary content until the user needs it.
Don't
Nest Accordions more than one level deep — it creates confusing navigation.
Do
Use description text for context that helps users decide whether to expand.
Don't
Put critical information inside collapsed Accordions — users may miss it.
Do
Use leadingIcon to reinforce the section's topic — gears for settings, bell for notifications.
Don't
Use Accordion for content the user needs to compare side-by-side — use tabs instead.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic names across all variants: container, icon-leading, content, trailing-icon. |
| C2 | Variant & Property Naming | Ready | Boolean properties use true/false. Variant keys use key=value syntax. leadingIcon and description are boolean visibility props. |
| C3 | Token Coverage | Ready | 10 tokens bound across all 6 variants. All colors, spacing, and typography fully tokenized. |
| C4 | Native Mappability | Ready | Header + content-body SLOT maps cleanly to DisclosureGroup (SwiftUI) and AnimatedVisibility (Compose). |
| C5 | Interaction State Coverage | Ready | Default, pressed, and disabled states covered across all 6 variants. Focus ring N/A — mobile OS handles natively. |
| C6 | Asset & Icon Quality | Ready | Chevrons are vector component instances. Leading icon is a SLOT placeholder accepting any icon instance. |
| C7 | Code Connect Linkability | Needs Refinement | No Code Connect mappings registered. Property structure is clean and ready for mapping — suggested paths below. |
| Aspect | Status | Notes |
|---|---|---|
| Component type | Ready | Proper Figma component set. |
| Variant naming | Ready | key=value syntax with true/false booleans. |
| Property naming | Ready | Clean 1:1 mapping to native params. |
| Layer naming | Ready | container, icon-leading, content, trailing-icon. |
| Token coverage | Ready | All 10 tokens bound — colors, spacing, and typography. |
| Asset quality | Ready | Chevrons are vector component instances. Icon slot is SLOT type. |
| Code Connect | Not Mapped | No mappings registered. Suggested paths below. |
2 Type values × 3 State values. leadingIcon and description are boolean visibility properties, not variant axes.
| Type | State | Node ID |
|---|---|---|
| Collapsed | Default | 16870:9289 |
| Expanded | Default | 16870:9298 |
| Collapsed | Pressed | 16919:864 |
| Expanded | Pressed | 16919:877 |
| Collapsed | Disabled | 16919:956 |
| Expanded | Disabled | 16919:969 |
Placeholder reverted after v1.3.0 restructure. Re-applied icon-leading name across all 6 current variants. FixedHeyMeowRnd-Bold.ttf (700, TTF, GPOS kerning, 959 glyphs) and BarkAda-SemiBold.ttf (600, TTF, GPOS kerning, 1050 glyphs) confirmed native-ready. BarkAda uses PostScript name BarkAda-SemiBold for iOS registration. ValidatedleadingIcon and description converted from variant axes to boolean visibility properties. Component set now has 2 Type values (Collapsed / Expanded) × 3 State values (Default / Pressed / Disabled)=6 variants total. Refinedsurface/default, border/subtle, text/primary, text/secondary, icon/placeholder, icon-chevron, surface/pressed, surface/content, surface/disabled, text/disabled. Fully resolves C3. FixedlabelDescription → description for cleaner 1:1 mapping to native params. Refinedcontent-body frame (360×80px) added at y=62 inside each container. Background: #F4F7FB (surface/content token). Border: #E5EBF4. Fully resolves C4. Fixedstate property added with values default / pressed / disabled. Fully resolves C5. FixedFrame to container. FixedPlaceholder to icon-leading. Fixedleading icon and label description converted from yes/no to true/false. Fully resolves C2. Fixedcontent-body frame added to all 12 expanded variants. Fixed in 1.2.0Action-list row with a 32 px leading icon, brand-blue label, trailing chevron, and a trailing Counter pill. 6 variants across Density (Compact / Expanded) × State (Default / Disabled / Loading). Shares the 2 × 3 matrix with its siblings Action List (18577:14545) and Action List - with Description (18577:14604) — the only delta is the trailing Counter instance.
EBCounter to the base transaction row. That should be a trailing slot (or a counter: Int? parameter that swaps in a Counter) on the base component — not a second component with a duplicated 2 × 3 density/state matrix. Shipping as a sibling doubles maintenance cost on every token or layout change, and the same anti-pattern will repeat for the "with Description" sibling.Used where a row needs to surface a pending count alongside the action — inbox folders, notification categories, or settings entries with outstanding items.
Flip Density + State. Counter is always on — the whole point of this variant sibling is to carry a trailing count.
Density is PascalCase; State is PascalCase here but state (lowercase) on Counter — inconsistent casing across the family. Loading skeleton is a generic strip instead of a trailing pill shape. C2| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | State=Default | Brand-blue label, brand-blue chevron, filled Counter pill (#072592 on #EEF2F9). |
| Disabled | Yes | Yes | State=Disabled | Muted label (#C2CFE5), muted chevron (#9BC5FD), empty Counter pill. |
| Loading | Yes | Yes | State=Loading | Avatar circle, long label skeleton, trailing 46 × 16 strip. The strip doesn't actually match the Counter pill shape — see open issue. |
| Pressed / Focused | Missing | Missing | Not modeled | No pressed / focused variants on the row. Inherited gap — same issue as base Transaction row. |
- Duplicated variant matrix. This sibling recreates the base row's 2 × 3 (
Density×State) matrix just to bolt on a trailing Counter. Every future change to the base row (radius, padding, token rename) has to be mirrored here. Fold into the base row via atrailingslot or acounter: Int?parameter. C1 · Layer Structure & Naming - Property casing is inconsistent across the family.
DensityandStateuse PascalCase, while the composedCounterchild uses lowercasestate. Pick one (recommend lowercase) and apply across every List Item and Counter property. C2 · Variant & Property Naming - Loading skeleton doesn't match the trailing Counter shape. The 46 × 16 strip is the generic "trailing icon" skeleton used on the base row — it doesn't look like a 24 × 24 pill. Either shape the skeleton to match or drop the Counter entirely in Loading (and let the skeleton stand in). C4 · Native Mappability
- No pressed / focused states. Action rows are interactive targets; they need pressed and focused visuals for native parity. Missing on the base row too — fix once at the base. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked until the consolidation lands — wiring this sibling directly would entrench the duplication. C7 · Code Connect Linkability
- Consolidate into base
Action List. Add atrailingslot to the base row (accepting any compact trailing view — Counter, Badge, Text, custom). Alternatively, add a typedcounter: Int?parameter that swaps in anEBCounterwhen set. Removes this sibling entirely and collapses the 12-variant base + with-Counter surface to 6. Apply the same treatment to the "with Description" sibling. Family - Adopt a Figma Slot for the trailing area. With the slot, this file's 6 variants disappear; consumers drop an existing
Counterinstance (node18482:71321) into the trailing slot of the base row. Maps cleanly to@ViewBuilder trailing(SwiftUI) /trailing: @Composable () -> Unit(Compose) for Code Connect. Slot - Normalize property casing. Rename
Density→densityandState→stateso the whole Transaction family (base, with Counter, with Description) matches the lowercase convention used on Counter and most of the DS. Rename - Shape the Loading skeleton or omit the trailing. If the base row's Loading skeleton stays, match the trailing skeleton to a 24 × 24 pill so consumers see the actual footprint. Easier path: drop the trailing skeleton and let the 46 × 16 strip stand in for all trailing content. State
- Add pressed / focused states at the base row. Action rows are tappable; native parity requires pressed + focused visuals. Fix on the base Transaction row once, and every "with X" sibling (or slotted consumer) inherits it. State
- Document the migration path. When the base row gets a trailing slot, deprecate this component and link consumers to the base row with Counter composition. Otherwise teams keep instancing the sibling and the duplication doesn't go away. Docs
6 variants: Density (Compact / Expanded) × State (Default / Disabled / Loading). Same 360 px row as the base Transaction row — only the trailing Counter is added.
360 × 56. 32 px icon, brand-blue label, chevron, trailing 24 × 24 filled Counter pill.
360 × 64. Same composition; 15 px vertical padding vs 11 px on Compact.
Muted label, muted chevron, empty Counter pill.
Expanded height + Disabled tokens.
Avatar circle + label line + 46 × 16 trailing strip. Strip shape doesn't match the Counter pill.
Same skeleton with 16 px padding.
| Role | Token | Default | Disabled | Loading |
|---|---|---|---|---|
| Row bg | main/action-list/color/default/bg | #FFFFFF | #FFFFFF | #FFFFFF |
| Label | main/action-list/color/default/label-brand | #005CE5 | – | – |
| Label (disabled) | main/action-list/color/disabled/label | – | #C2CFE5 | – |
| Chevron | main/action-list/color/default/chevron | #005CE5 | #9BC5FD | – |
| Counter bg | main/counter/color/filled/bg | #EEF2F9 | #EEF2F9 | – |
| Counter label (filled) | main/counter/color/filled/label | #072592 | – | – |
| Counter label (empty) | main/counter/color/empty/label | – | #C2CFE5 | – |
| Skeleton bar | bg/color-bg-strong | – | – | #EEF2F9 |
| Row shadow | Depth/D0 | drop-shadow(0 1 3 0 #E8EEF2C9) | ||
Counter colors are owned by the Counter component's variable collection (main/counter/color/*).
| Property | Token | Value |
|---|---|---|
| Row width | — | 360px |
| Row height (Compact) | — | 56px |
| Row height (Expanded) | — | 64px |
| Row padding H | space/space-12 | 12px |
| Row padding V (Compact) | — | 11px |
| Row padding V (Expanded) | — | 15px |
| Icon → label gap | space/space-12 | 12px |
| Label → trailing gap | space/space-16 | 16px |
| Icon size | — | 32 × 32 |
| Chevron size | — | 32 × 32 |
| Counter size | — | 24 × 24 (min) · hugs digits |
| Counter pad H | space/space-8 | 8px |
| Counter radius | radius/radius-round | pill (99999) |
| Row radius | radius/radius-2 | 6px |
| Element | DS text style | Spec |
|---|---|---|
| Label | Primary/Label/Large | Proxima Soft Bold · 18 / 18 · +0.25 |
| Counter | Primary/Label/Small | Proxima Soft Bold · 14 / 14 · +0.25 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios" import EBDesignSystem
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:list:1.0.0") } import com.eastblue.ds.components.EBListItemTransaction
| Figma (today) | Figma (proposed) | SwiftUI | Compose | Notes |
|---|---|---|---|---|
| (separate component) | trailing Slot on base row | @ViewBuilder trailing | trailing: @Composable () -> Unit | Drop this sibling; consumer composes base row + Counter via slot. |
| Density | density (renamed) | density: .compact | .expanded | density: EBListDensity | Lowercase for consistency with Counter + most of DS. |
| State | state (renamed) | state: .default | .disabled | .loading | state: EBListState | Lowercase; adopt pressed + focused later. |
| icon (bool) | leading Slot | @ViewBuilder leading | leading: @Composable () -> Unit | Match trailing — slot over bool. |
| label | label | label: String | label: String | Unchanged. |
| chevron (bool) | chevron | chevron: Bool=true | chevron: Boolean=true | Keep as a bool; chevron is fixed. |
| counter (bool) | derived from trailing slot | — | — | Delete — slot presence drives rendering. |
All snippets use the consolidated base EBListItemTransaction with a trailing EBCounter — the exact composition this sibling encodes today.
// Default — Compact density, trailing Counter EBListItemTransaction(label: "Notifications") { EBAvatar(initials: "N") } trailing: { EBCounter(count: unreadCount) } // Expanded density EBListItemTransaction(label: "Promos") { EBAvatar(initials: "P") } trailing: { EBCounter(count: 2) } .density(.expanded) // Disabled row — counter stays in empty state EBListItemTransaction(label: "Archive") { EBAvatar(initials: "A") } trailing: { EBCounter(count: 0) } .disabled(true) // Loading — trailing slot omitted; skeleton fills EBListItemTransaction.loading()
// Default — Compact density, trailing Counter EBListItemTransaction( label = "Notifications", leading = { EBAvatar(initials = "N") }, trailing = { EBCounter(count = unreadCount) } ) // Expanded density EBListItemTransaction( label = "Promos", density = EBListDensity.Expanded, leading = { EBAvatar(initials = "P") }, trailing = { EBCounter(count = 2) } ) // Disabled row EBListItemTransaction( label = "Archive", enabled = false, leading = { EBAvatar(initials = "A") }, trailing = { EBCounter(count = 0) } ) // Loading — trailing slot omitted; skeleton fills EBListItemTransaction(state = EBListState.Loading)
| Requirement | iOS | Android |
|---|---|---|
| Row role | Wrap in Button / NavigationLink for tappable semantics | Apply Modifier.clickable(...) + Role.Button |
| Counter label | Compose accessibility label: "Notifications, 5 unread" — don't let VoiceOver read the digit alone | Merge into row: contentDescription="Notifications, 5 unread" |
| Disabled | .disabled(true) — drops from hit-testing + dims label/chevron/counter | enabled=false on clickable modifier |
| Loading | Announce "Loading"; hide skeleton children from a11y tree | Modifier.semantics { liveRegion=Polite } + hide skeletons |
| Chevron | Decorative — .accessibilityHidden(true) | contentDescription=null |
- Compose: use the base
EBListItemTransactionwith a trailingEBCounterslot instead of this sibling. - Hide the Counter when the count is zero if the zero state is noise (most inbox-style lists).
- Pair the numeric Counter with a screen-reader label that adds context ("5 unread").
- Don't ship this as a separate component in native — it duplicates the base row.
- Don't put Badge labels (New, Draft) in the trailing slot and call it a Counter — use Badge via the same slot.
- Don't build additional "with X" siblings (with Description, with Toggle, …). They're all trailing-slot use cases.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Sibling duplicates the base Transaction row matrix. Consolidate via trailing slot. |
| C2 | Variant & Property Naming | Partial | Density/State PascalCase mismatches lowercase Counter and most of DS. |
| C3 | Token Coverage | Ready | All colors + spacing bound. Uses main/action-list/* + main/counter/*. |
| C4 | Native Mappability | Partial | HStack/Row maps cleanly. Loading skeleton's 46 × 16 trailing strip doesn't match a Counter pill. |
| C5 | Interaction State Coverage | Partial | No pressed / focused variants — inherited from base row. |
| C6 | Asset & Icon Quality | Ready | Chevron is a vector instance; icon is a swap placeholder — same pattern as other List Items. |
| C7 | Code Connect Linkability | Not Mapped | Do not wire this sibling — map the base row with trailing slot after consolidation. |
Density (2) × State (3)=6 variants. Identical matrix to the base Transaction row.
| # | Density | State | Node ID | Dimensions |
|---|---|---|---|---|
| 1 | Compact | Default | 18577:14638 | 360 × 56 |
| 2 | Expanded | Default | 18577:14647 | 360 × 64 |
| 3 | Compact | Disabled | 18577:14656 | 360 × 56 |
| 4 | Expanded | Disabled | 18577:14665 | 360 × 64 |
| 5 | Compact | Loading | 18577:14674 | 360 × 56 |
| 6 | Expanded | Loading | 18577:14679 | 360 × 64 |
Density/State mismatch lowercase state on composed Counter. OpenAn action-list row with a secondary description line beneath the primary label. 3 variants across State (Default / Disabled / Loading). Sibling of base List (18577:14545) and List - with Counter (18577:14637) — all three share the same container, tokens (main/action-list/...), and trailing CTA + chevron anatomy.
description slotVStack, Material 3's ListItem has supportingContent. Collapse all three siblings (List, with Description, with Counter) into one Action List component with optional description and trailing slots. This also closes the Density-coverage gap (base List has Compact + Expanded; this variant does not).Contexts are illustrative. Description rows appear in settings lists, notification preferences, and profile menus where each row needs a subtitle explaining the action.
Toggle state, icon, trailing CTA, and chevron. Loading state replaces content with shimmer rows.
description slot on the base component would handle this.Primary/Label/Light/Base + Primary/Multi-line Label/Light/Fine), background, and spacing.#c2c6cf circle placeholder, not an instance of List Item Asset. C5C1| State | iOS | Android | Figma property | Notes |
|---|---|---|---|---|
| Default | Text + subtitle render; chevron visible | Text + supporting text render; chevron visible | state=Default | Label #0A2757, description #6780A9 |
| Disabled | .disabled(true) — tap ignored, opacity reduced | enabled=false — 38% content alpha | state=Disabled | Label + description both recolor to #c2cfe5, CTA to #9bc5fd |
| Loading | Skeleton with redacted(reason:) or shimmer overlay | Skeleton shimmer via Modifier.placeholder(...) | state=Loading | Two shimmer lines replace text; trailing icon becomes a 53px shimmer block |
| Pressed | Not defined in Figma | Not defined in Figma | — | Missing — native pressed token should map to main/action-list/color/pressed/bg (to be added) C5 |
- Sibling component duplicates base List anatomy. A 12/14 description line is a single optional parameter on every native list primitive (SwiftUI secondary label, Material
supportingContent). Standalone sibling forces consumers to swap whole components instead of flipping one prop. C4 · Native Mappability - Leading asset is a raw
#c2c6cfplaceholder. The icon container is a 32px filled circle with a hard-coded gray — not an instance of List Item Asset, not bound to a token. Consumers can override via instance-swap but the default is visually broken. C1 · Layer Structure & Naming - Missing Density axis. Base List ships
Density=Compact(48px) andDensity=Expanded(56px). This variant skips the axis entirely, producing a 70–76px row with no Compact counterpart. C2 · Variant & Property Naming - No Pressed state. Only Default / Disabled / Loading are modeled. Action lists are tappable — pressed styling should live in Figma as a token so native
.pressed/ripplecan reference it. C5 · Interaction State Coverage - Code Connect mappings not registered. Blocked until family consolidation lands — no point mapping a component that will be removed. C7 · Code Connect Linkability
- Consolidate the List family into one Action List component. Collapse base List, List - with Counter, and Action List - with Description into a single component with optional
description: String?andtrailingslot (CTA text / Counter / custom). Variant math drops from 6 + 3 + 3=12 down to 3 states × 2 densities=6, with description and counter becoming boolean/slot properties. Family - Replace the leading-icon placeholder with a Figma Slot. The raw 32px
#c2c6cfcircle should become a namedleadingslot accepting any List Item Asset instance or a 32 × 32 icon. Maps 1:1 to SwiftUI@ViewBuilder leading/ ComposeleadingContent. Slot - Add Density × Pressed coverage to the merged component. The consolidated Action List should carry Compact (48px) + Expanded (56px) from base List, plus a Pressed visual state so tokens like
main/action-list/color/pressed/bgexist for native ripple/highlight. State - Rename the component family to match the token namespace. Tokens use
main/action-list/...but Figma names the componentList. Rename the merged component toAction Listso Figma, tokens, and native API (EBActionList) all agree. Rename
3 variants split by State. Default shows full row; Disabled dims label + description + CTA + chevron; Loading swaps content for shimmer lines.
Active row. Leading icon + label + description + optional CTA + chevron.
Non-interactive. Label + description recolor to #c2cfe5; CTA + chevron use #9bc5fd.
Skeleton. Two shimmer lines replace text; trailing is a 53px shimmer block.
| Role | Token | Default | Disabled | Loading |
|---|---|---|---|---|
| Row bg | main/action-list/color/{state}/bg | #FFFFFF | #FFFFFF | #FFFFFF |
| Label | main/action-list/color/{state}/label | #0A2757 | #C2CFE5 | – |
| Description | main/action-list/color/{state}/description | #6780A9 | #C2CFE5 | – |
| CTA label | main/action-list/color/default/label-link | #005CE5 | #9BC5FD | – |
| Chevron | main/action-list/color/{state}/chevron | #005CE5 | #9BC5FD | – |
| Skeleton line / block | bg/color-bg-strong | – | – | #EEF2F9 |
| Leading placeholder | — | #C2C6CF | #C2C6CF | – |
Leading placeholder fill is hardcoded in the symbol — not bound to a token. Flagged under C1.
| Property | Token | Value |
|---|---|---|
| Row width | — | 360px (fixed) |
| Row height | — | 60px (hug, 2 text lines + 6px gap) |
| Outer padding (Default / Disabled) | space/space-12 | 12px all sides |
| Loading padding | space/space-12, space/space-24 | 14px vertical · 12px left · 24px right |
| Icon → text gap | space/space-12 | 12px |
| Label → description gap | space/space-6 | 6px |
| Leading icon size | — | 32 × 32 |
| Chevron wrapper padding | space/space-4 | 4px top/bottom · 4px left/right |
| Chevron icon size | — | 24 × 24 |
| Corner radius | radius/radius-0 | 0 (square) |
| Element | DS text style | Spec |
|---|---|---|
| Label | Primary/Label/Light/Base | Proxima Soft Semibold · 16 / 16 · tracking 0.25 |
| Description | Primary/Multi-line Label/Light/Fine | Proxima Soft Semibold · 12 / 14 · tracking 0.5 |
| CTA | Primary/Label/Light/Base | Proxima Soft Semibold · 16 / 16 · tracking 0.25 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:list:1.0.0") }
| Figma | SwiftUI | Compose | Notes |
|---|---|---|---|
| label | title: String | title: String | Primary label |
| description? | subtitle: String? | supportingContent: String? | Optional — this component becomes description !=nil on base List |
| icon (Slot) | @ViewBuilder leading | leadingContent: @Composable () -> Unit | 32 × 32 — accepts List Item Asset or custom icon |
| trailingComponent (Slot) | @ViewBuilder trailing | trailingContent: @Composable () -> Unit | CTA text / Counter / custom |
| chevron | showChevron: Bool | showChevron: Boolean | Renders a 24 × 24 chevron after the trailing slot |
| bottomBorder | showDivider: Bool | showDivider: Boolean | Bottom hairline; handled by parent List in native |
| state | .disabled(true), loading modifier | enabled=false, loading param | Disabled + Loading handled via standard APIs |
// With description + CTA + chevron EBActionList( title: "Notifications", subtitle: "Receive alerts for new transactions", leading: { EBListItemAsset(.icon("bell")) }, trailing: { Text("Edit").foregroundStyle(.blue) }, showChevron: true ) // Disabled EBActionList(title: "Premium", subtitle: "Unlock higher limits") .disabled(true) // Loading EBActionList(title: "", subtitle: "") .redacted(reason: .placeholder)
// With description + CTA + chevron EBActionList( title = "Notifications", supportingContent = "Receive alerts for new transactions", leadingContent = { EBListItemAsset(Icon.Bell) }, trailingContent = { Text("Edit", color = Color.Blue) }, showChevron = true ) // Disabled EBActionList( title = "Premium", supportingContent = "Unlock higher limits", enabled = false ) // Loading EBActionList(title = "", loading = true)
| Requirement | iOS | Android |
|---|---|---|
| Touch target | Row is tappable via Button wrapper or .onTapGesture; min 44pt | Modifier.clickable(...); min 48dp |
| Combined label | .accessibilityElement(children: .combine) so VoiceOver reads "Label, description, button" | Modifier.semantics(mergeDescendants=true) { role=Role.Button } |
| Disabled state | .disabled(true) — VoiceOver announces "dimmed" | enabled=false — TalkBack announces "disabled" |
| Loading state | .accessibilityLabel("Loading") on skeleton rows | Modifier.semantics { contentDescription="Loading" } |
| Chevron semantics | Decorative — .accessibilityHidden(true) | contentDescription=null |
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Leading asset is a raw #c2c6cf circle placeholder, not a List Item Asset instance or a Figma Slot. |
| C2 | Variant & Property Naming | Partial | Property names are clean (state, icon, chevron, description) but the Density axis from base List is missing. |
| C3 | Token Coverage | Ready | All label, description, CTA, chevron, bg colors bound to main/action-list/.... Spacing uses space/*. |
| C4 | Native Mappability | Rework | Sibling component for what natives handle as a single parameter. Consolidate with base List. |
| C5 | Interaction State Coverage | Partial | Default / Disabled / Loading only — no Pressed. Action lists are tappable; pressed token needed. |
| C6 | Asset & Icon Quality | Ready | Chevron is a vector instance. Leading placeholder is decorative, user-provided via instance swap. |
| C7 | Code Connect Linkability | Not Mapped | Blocked — consolidate family first, then map once. |
| State | Width × Height | Padding | Notes | Node ID |
|---|---|---|---|---|
| Default | 360 × 60 | 12 all sides | Label + description + CTA + chevron | 18577:14605 |
| Disabled | 360 × 60 | 12 all sides | Label + description recolored; CTA + chevron dimmed | 18577:14617 |
| Loading | 360 × 60 | 14 V · 12 L · 24 R | Two shimmer lines + 53px trailing shimmer block | 18577:14629 |
#c2c6cf, not an instance of List Item Asset. Opendescription slot. OpenA tappable row used in action-list menus (Settings, Help, Profile sub-screens) — leading icon, label, optional description, trailing CTA / counter / chevron. Today it ships as three sibling components: List (18577:14545, 6 variants), List - with Counter (18577:14637, 6 variants), and List - with Description (18577:14604, 3 variants) — 15 variants total across the family. Each sibling uses a different label treatment and a slightly different padding scheme.
List component with description?: String, trailing: .cta | .counter | .chevron | .none, plus a named leading slot for the icon. Align label typography across the family (currently Semibold 16 Neutral vs. Bold 18 Brand). Add a Pressed state — these rows are primary nav targets. Reconcile with List Item (display-only body rows) and clarify when to use which.Action-list rows stack inside Settings / Profile / Help menus. A typical screen mixes variants with/without description and with/without trailing counter.
Swap between the 3 sibling shapes and their states. Notice how the Counter variant reads in a different label style than the other two — that's the "Consistent" warning below.
main/action-list/* tokens. Loading skeleton uses bg/color-bg-strong for placeholders. Leaked internal spacer annotations (_space_2, _space_16) are rendered inside production instances. C1List and List - with Description use Semibold 16 Neutral (#0A2757); List - with Counter uses Bold 18 Brand Blue (#005CE5). C2trailing enum. C4C6| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | State=Default | Baseline row. Label in Neutral Dark (or Brand Blue on the Counter variant). |
| Disabled | Yes | Yes | State=Disabled | Label → #C2CFE5, chevron → #9BC5FD, CTA → #9BC5FD, counter bg stays #EEF2F9 but label → #C2CFE5. |
| Loading | Yes | Yes | State=Loading | Icon becomes a neutral ring; label + trailing become 16 px pill placeholders filled with #EEF2F9. |
| Pressed | Missing | Missing | Not built | Action rows are tap targets — a pressed state (row tint + possibly label darken) is a baseline expectation for native. |
| Focused | Missing | Missing | Not built | TV / keyboard focus ring not defined. Android a11y also relies on it. |
- Three sibling components for one row pattern.
List,List - with Counter, andList - with Descriptiondiffer only by the presence of a description line and/or a trailing counter. Collapse into a singleListcomponent with optionaldescriptionand atrailingunion. C1 · Layer Structure & Naming - Label typography diverges across the family.
List+List - with Descriptionuse Proxima Soft Semibold 16 / Neutral Dark (#0A2757).List - with Counteruses Proxima Soft Bold 18 / Brand Blue (#005CE5). Same row family should read as one thing. Pick one token (labelorlabel-brand) and one size. C2 · Variant & Property Naming - Leaked spacer annotation layers. Internal
_space_2(4115:3220) and_space_16(21:40139) annotation frames are rendered as opacity-0 layers inside every production instance. Artifacts of authoring, not part of the public component. Remove or move to a separate documentation artboard. C1 · Layer Structure & Naming - Leading icon is a gray placeholder. All three siblings default to a
#C2C6CFfilled 32 px circle under a frame named Placeholder. Same instance-swap anti-pattern as List Item. Adopt a Figma Slot so consumers can drop in a real icon (or an Avatar). C6 · Asset & Icon Quality - No Pressed state. These rows are the primary tap surface for navigation menus. The State enum exposes only Default / Disabled / Loading. Add Pressed (tinted bg and/or chevron darken). C5 · Interaction State Coverage
- Trailing content is baked per sibling. CTA text lives on the base, a filled Counter lives on the Counter sibling, and a chevron appears only sometimes. Introduce a
trailingenum (.cta(String) | .counter(Int) | .chevron | .none) so one component covers all three patterns. Maps cleanly to native enums. C4 · Native Mappability - Code Connect mappings not registered. Blocked on the consolidation + slot adoption. Adding mappings for three siblings would cement the wrong schema. C7 · Code Connect Linkability
- Consolidate into one
Listrow. One component with propertieslabel: String,description?: String,trailing: .cta(String) | .counter(Int) | .chevron | .none,density: .compact | .expanded,state: .default | .pressed | .disabled | .loading, plus aleadingslot. Replaces 15 variants across 3 components with roughly 4 × 4=16 state-permutations of one schema. Family - Add a
leadingFigma Slot for the icon. Maps 1:1 to@ViewBuilder(SwiftUI) and a@Composableslot (Compose). Accepts Icon, Avatar, or a custom 32 px component. Remove the placeholder fill entirely — empty slot means no leading. Slot - Reconcile label typography. Pick one: either Neutral Dark Semibold 16 (matches
List Item+ most action rows) or Brand Blue Bold 18 (matches the current Counter sibling). Neutral is the safer default — Brand Blue reads like a link, which the whole row already behaves as. Apply the choice to all three shapes. Token - Add Pressed (and ideally Focused) states. Pressed=
bgtints to#F4F6FA, chevron / CTA darkens one step. Focused=2 px brand ring at 2 px offset. Baseline for native row components. State - Remove
_space_2/_space_16spacer annotations. These are authoring artifacts. Move to a separate "Annotations" artboard or delete once the auto-layout is settled. They export as opacity-0 layers to consumers. Composition - Disambiguate vs
List Item. This component is tappable action navigation (icon + label + trailing CTA/counter/chevron).List Itemis display body rows (bullet + text for terms/steps). Document the distinction and cross-link the two — today the names don't telegraph which is which. Docs - Rename the family.
Listcollides with the native-platform word for a scroll container, and the "Action List" shorthand the team uses internally doesn't appear in Figma. ConsiderActionListRow/EBActionListRow— disambiguates from display lists and from plain list items. Rename
Three sibling components, 15 variants combined. Previews show the Default state of each shape.
Baseline row. 6 variants (State × Density). Label in Neutral Dark Semibold 16. Trailing CTA text + 24 px chevron icon. 360 × 48 (compact) / 360 × 56 (expanded).
Adds a trailing Counter pill. 6 variants (Density × State). Card-like container with radius-2 (6 px) corners and Depth/D0 drop-shadow — differs from the base's flat row. Label switches to Bold 18 Brand Blue. 360 × 56 / 360 × 64.
Adds a secondary description line under the label. 3 variants (State only — no Density axis). Label matches the base (Semibold 16 Neutral). Description uses Semibold 12 / tracking-wider / main/action-list/color/default/description (#6780A9). 360 × 60.
| Role | Token | Default | Disabled | Loading |
|---|---|---|---|---|
| Row bg | main/action-list/color/default/bg | #FFFFFF | #FFFFFF | #FFFFFF |
| Label (base & with-description) | main/action-list/color/default/label | #0A2757 | #C2CFE5 | — |
| Label (with-counter) | main/action-list/color/default/label-brand | #005CE5 | #C2CFE5 | — |
| Description | main/action-list/color/default/description | #6780A9 | #C2CFE5 | — |
| Trailing CTA label | main/action-list/color/default/label-link | #005CE5 | #9BC5FD | — |
| Chevron | main/action-list/color/default/chevron | #005CE5 | #9BC5FD | — |
| Counter bg | main/counter/color/filled/bg | #EEF2F9 | #EEF2F9 | — |
| Counter label | main/counter/color/filled/label | #072592 | #C2CFE5 | — |
| Skeleton fill | bg/color-bg-strong | — | — | #EEF2F9 |
Pressed state has no defined colors — open issue.
| Property | Token | Value |
|---|---|---|
| Frame width | — | 360px (fill container in product) |
| Row height — base | — | 48 (compact) / 56 (expanded) |
| Row height — with Counter | — | 56 (compact) / 64 (expanded) |
| Row height — with Description | — | 60 (no density axis) |
| Icon size | — | 32 × 32 |
| Icon → label gap | space/space-12 | 12px |
| Wrapper padding (compact) | space/space-12 + 7/11 | 12px / 7px (compact) · 12px / 11px (expanded) |
| Description gap | space/space-6 | 6px |
| Counter radius | radius/radius-round | 99999px (pill) |
| Counter size | — | 24 × 24 (filled) / h24 (empty) |
| Card radius (with Counter) | radius/radius-2 | 6px |
| Card shadow (with Counter) | Depth/D0 | 0 1 3 0 · #E8EEF2C9 |
| Chevron size | — | 24 × 24 (base + with-description) / 32 × 32 (with-counter) |
| Spacer annotations | — | _space_2, _space_16 leak through (opacity 0) |
| Element | DS text style | Spec |
|---|---|---|
| Label — base & with-description | Primary/Label/Light/Base | Proxima Soft Semibold · 16 / 16 · +0.25 |
| Label — with-counter | Primary/Label/Large | Proxima Soft Bold · 18 / 18 · +0.25 |
| Description | Primary/Multi-line Label/Light/Fine | Proxima Soft Semibold · 12 / 14 · +0.5 |
| Trailing CTA | Primary/Label/Light/Base | Proxima Soft Semibold · 16 / 16 · +0.25 |
| Counter label | Primary/Label/Small | Proxima Soft Bold · 14 / 14 · +0.25 |
Two different label styles across the family is the core C2 Warn — reconcile to one text style.
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:list:1.0.0") }
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
| 3 sibling components | 1 component: List | EBActionListRow | EBActionListRow |
| icon (Placeholder) | leading (Slot) | @ViewBuilder leading | leading: @Composable () -> Unit |
| label: String | label: String | label: String | label: String |
| description (only on sibling) | description?: String | description: String? | description: String?=null |
| trailingComponent (bool) / counter (bool) / chevron (bool) | trailing: .cta(String) | .counter(Int) | .chevron | .none | trailing: EBRowTrailing | trailing: EBRowTrailing |
| density: Compact/Expanded | density: .compact / .expanded | .controlSize(.regular / .large) | density: EBDensity |
| state: Default/Disabled/Loading | state: .default / .pressed / .disabled / .loading | .disabled(Bool) + intrinsic press + loading: Bool | enabled: Boolean + loading: Boolean |
| bottomBorder: Bool | bottomBorder: Bool | divider: Bool | divider: Boolean |
| (not modeled) | onTap | action: () -> Void | onClick: () -> Unit |
// Base — icon + label + CTA + chevron EBActionListRow("Payment methods", trailing: .cta("View")) { Image(systemName: "creditcard.fill") } action: { openPaymentMethods() } // With counter — shows 3 pending items EBActionListRow("Notifications", trailing: .counter(3)) { Image(systemName: "bell.fill") } action: { openNotifications() } // With description + chevron EBActionListRow( "Profile", description: "Name, photo, and contact info", trailing: .chevron ) { Image(systemName: "person.crop.circle") } action: { openProfile() } // Loading EBActionListRow.skeleton()
// Base — icon + label + CTA + chevron EBActionListRow( label = "Payment methods", leading = { Icon(Icons.Default.CreditCard, contentDescription = null) }, trailing = EBRowTrailing.Cta("View"), onClick = { openPaymentMethods() } ) // With counter EBActionListRow( label = "Notifications", leading = { Icon(Icons.Default.Notifications, contentDescription = null) }, trailing = EBRowTrailing.Counter(3), onClick = { openNotifications() } ) // With description + chevron EBActionListRow( label = "Profile", description = "Name, photo, and contact info", leading = { Icon(Icons.Default.Person, contentDescription = null) }, trailing = EBRowTrailing.Chevron, onClick = { openProfile() } ) // Loading EBActionListRow.Skeleton()
| Requirement | iOS | Android |
|---|---|---|
| Row as button | Wrap row in Button; mark decorative leading icon with .accessibilityHidden(true). | Modifier.clickable { … }.semantics(mergeDescendants=true) { role=Role.Button }. |
| Combined label | Announce label + description + trailing counter as one phrase: "Notifications, 3 unread". | Same — build via contentDescription. |
| Touch target | Minimum 44 × 44 — expanded density hits this; compact (48 px row) is safe; ensure whole row is the tap target, not just the chevron. | Minimum 48 × 48dp — same. |
| Loading | Announce "Loading" once; disable tap while loading. | Same — enabled=false plus contentDescription="Loading". |
| Focus ring | Provide a focused treatment for external keyboards. | Focus ring required for TV / external keyboards. |
- Use for tappable settings / help / profile menu rows that navigate to another screen.
- Use description for secondary context — keep it short (one line).
- Use counter for rows with a pending-item count (Notifications, Inbox).
- Always provide a leading icon — empty rows feel broken.
- Don't use for display body rows (terms, onboarding steps) — use List Item.
- Don't use for transaction history rows — use Generic Transaction Card.
- Don't mix the Counter and non-Counter siblings in the same list — the inconsistent label treatment reads as a bug until the families are reconciled.
- Don't omit the chevron on rows that navigate — users rely on it as an affordance cue.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | 3 sibling components for one pattern. Spacer annotations leak into production. |
| C2 | Variant & Property Naming | Rework | Inconsistent label typography across siblings. Counter sibling uses a different text style than its peers. |
| C3 | Token Coverage | Ready | All colors / paddings bound to main/action-list/*, space/*, radius/* tokens. |
| C4 | Native Mappability | Needs Refinement | Maps cleanly once trailing is a single enum instead of three booleans across three components. |
| C5 | Interaction State Coverage | Rework | No Pressed / Focused. Disabled + Loading present. |
| C6 | Asset & Icon Quality | Needs Refinement | Leading icon is a gray placeholder circle — move to a Figma Slot. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on consolidation. Mapping three siblings would cement the wrong schema. |
3 sibling components. Base + Counter multiply State (3) × Density (2)=6 each. Description axis=State (3). Total 6 + 6 + 3=15 variants.
| Component | Axes | Count | Node |
|---|---|---|---|
| List | State (3) × Density (2) | 6 | 18577:14545 |
| List - with Counter | Density (2) × State (3) | 6 | 18577:14637 |
| List - with Description | State (3) | 3 | 18577:14604 |
View full per-variant breakdown (15 rows)
| # | Component | State | Density | Dimensions | Node |
|---|---|---|---|---|---|
| 1 | List | Default | Compact | 360 × 48 | 18577:14546 |
| 2 | List | Default | Expanded | 360 × 56 | 18577:14557 |
| 3 | List | Disabled | Compact | 360 × 48 | 18577:14568 |
| 4 | List | Disabled | Expanded | 360 × 56 | 18577:14579 |
| 5 | List | Loading | Compact | 360 × 48 | 18577:14590 |
| 6 | List | Loading | Expanded | 360 × 56 | 18577:14597 |
| 7 | List - with Counter | Default | Compact | 360 × 56 | 18577:14638 |
| 8 | List - with Counter | Default | Expanded | 360 × 64 | 18577:14647 |
| 9 | List - with Counter | Disabled | Compact | 360 × 56 | 18577:14656 |
| 10 | List - with Counter | Disabled | Expanded | 360 × 64 | 18577:14665 |
| 11 | List - with Counter | Loading | Compact | 360 × 56 | 18577:14674 |
| 12 | List - with Counter | Loading | Expanded | 360 × 64 | 18577:14679 |
| 13 | List - with Description | Default | — | 360 × 60 | 18577:14605 |
| 14 | List - with Description | Disabled | — | 360 × 60 | 18577:14617 |
| 15 | List - with Description | Loading | — | 360 × 60 | 18577:14629 |
List row. Reconcile label typography. Add Pressed state. Open_space_2 / _space_16 are authoring artifacts exported as opacity-0 layers. Opentrailing enum. Open#C2C6CF circle. Adopt a Figma Slot. Openmain/action-list/*. NotedThe canonical surface for any ad, promo, or sponsored placement across the app. One component, one size enum with seven values grouped into three size families: banner (IAB-standard, AdMob-driven), promo (product-owned dashboard tiles with image + caption), and hero (full-width hero banners, optionally wrapped in the DS Carousel). A single content slot accepts an AdMob view, an image, or an illustration; an orthogonal isLoading boolean drives the skeleton state; an optional caption string anchors promo and hero placements. Replaces three legacy components (Ads On Receipt, Ad Space - Group - Large, Dashboard Promo Cards) and retires two asset libraries (Placeholder Banner, Promo Cards Images).
- Ads On Receipt(node
18563:9789) — IAB banner sizes 320×50, 320×100, 300×250. Becomesbanner-sm,banner-lg,banner-mrec. - Ad Space - Group - Large(node
18563:9808) — hero banners with single + "carousel preview" row at 320/360 widths. Becomeshero-smandhero-md; multi-ad layouts now compose inside the DS Carousel. - Dashboard Promo Cards(node
18563:9917) — dashboard tiles at 131×126 and 224×200. Becomespromo-smandpromo-md. - Placeholder Banner(node
18563:9937) and Promo Cards Images(node18563:9928) — asset libraries for the above. Retired; media now flows through thecontentslot.
Each of the three size families has a canonical home in the app: banners inline within transaction flows, promos on the dashboard tile grid, and heroes full-width on home or category surfaces.
Flip size across the seven canonical values. Toggle isLoading to see the skeleton state. Edit the caption (promo / hero only).
content slot — no hardcoded rasters, no placeholder "replace me" assets.<family>-<size>. Content and state are orthogonal axes. Caption typography uses the same DS text style across all promo and hero sizes.hero-md), into list rows for inline banners, and into the dashboard grid for promo tiles. No sibling "Ad Carousel" or "Ad Group" needed.| Size | Family | Dimensions | iOS primitive | Android primitive | Content slot |
|---|---|---|---|---|---|
banner-sm | banner | 320 × 50 | GADBannerView | AdView | AdMob ad unit (IAB Mobile Banner) |
banner-lg | banner | 320 × 100 | GADBannerView | AdView | AdMob ad unit (IAB Large Banner) |
banner-mrec | banner | 300 × 250 | GADBannerView | AdView | AdMob ad unit (IAB Medium Rectangle) |
promo-sm | promo | 131 × 126 | custom EBAdSpace | custom composable | 4:3 image + optional caption underneath |
promo-md | promo | 224 × 200 | custom EBAdSpace | custom composable | 3:2 image + caption |
hero-sm | hero | 296 × 174 | custom EBAdSpace | custom composable | 17:10 image with optional caption overlay |
hero-md | hero | 336 × 174 | custom EBAdSpace | custom composable | 15:8 image, composes in DS Carousel for multi-ad rails |
isLoading | — | — | RedactedShape | shimmer composable | Skeleton placeholder regardless of family; consistent surface radius per size |
- Adoption — deprecate and delete the three legacy components. Swap all Figma usages of Ads On Receipt, Ad Space - Group - Large, and Dashboard Promo Cards to
Ad Spacewith the matchingsize. Delete the old components plus the Placeholder Banner and Promo Cards Images asset libraries once zero-usage is confirmed. Family - Asset pipeline — ship promo and hero imagery as product assets, not DS assets. Product teams export 1×/2×/3× image assets from their marketing pipeline and pass them into the
contentslot. The DS ships the container, typography, radius, and skeleton only — no imagery. This is what retires the 8-variant Placeholder Banner and 6-variant Promo Cards Images asset sets. Asset - Carousel composition — no "Ad Carousel" component. Multi-ad rails (e.g. a horizontal row of
hero-md) are authored as<Carousel><AdSpace size="hero-md" /><AdSpace size="hero-md" />…</Carousel>. The oldcarousel=yespseudo-variant is retired because it was a static 3-card Figma preview, not a runtime carousel. Document this composition pattern in the Carousel and Ad Space guidelines. Composition - Tokens — propose a
main/ad-space/color/*namespace. Shipmain/ad-space/color/surface(card background),main/ad-space/color/caption(caption text), andmain/ad-space/color/loading-skeleton(shimmer fill). Today the legacy components reuse genericbg/color-bg-mainandbg/color-bg-strong; a dedicated namespace makes cross-family theming (dark mode, partner-branded surfaces) tractable. Token - Telemetry — bake impression and tap tracking into the native component. The iOS
EBAdSpaceview and Android composable should emitonImpression(50% visible for ≥1s) andonTapcallbacks. AdMob-backedbanner-*sizes get this for free;promo-*andhero-*need product-side reporting. Document the contract in the Code tab so consumer teams wire analytics consistently. Docs - A11y — treat ads as labeled buttons, not decorative images. Every Ad Space is tappable and leads somewhere; the whole surface must expose a single accessibility label (caption + "Advertisement" trait). Banner family should announce "Advertisement" as its accessibility hint per App Store / Play Store disclosure norms. A11y
- Caption — optional, typography-constrained, never mandatory. Promo and hero families accept an optional caption string; banner family has none (AdMob renders its own chrome). Caption uses
Secondary/Bold/Caption— one line for promo-sm, up to two lines for promo-md / hero-*. Empty caption hides the label slot; it does not reserve space. Slot - See siblings:Carousel Card and the broader Carousel family — Ad Space heroes wrap inside the DS Carousel; keep skeleton treatment aligned across card primitives. Family
18563:9789IAB-standard banner sizes driven by AdMob. The DS provides a fixed-dimension surface with a 4px corner radius and a subtle "Ad" label marker; the ad SDK renders the creative inside the content slot.
320 × 50320 × 100300 × 2504 (radius/radius-1)0 (ad fills surface)| ROLE | TOKEN | DEFAULT | LOADING |
|---|---|---|---|
| Surface | ad-space/color/surface | #FFFFFF | #EEF2F9 |
| Skeleton fill | ad-space/color/loading-skeleton | — | #EEF2F9 |
| "Ad" marker | text/color-text-subtle | #6780A9 | — |
Secondary/Bold/CaptionBarkAda Semibold · 10 / 14 · +0.25GADBannerViewAdView18563:9917Product-owned dashboard tiles. Image fills the upper portion; an optional caption sits beneath. Both sizes ship with the same caption typography and 8px corner radius — they differ only in image aspect ratio.
131 × 126224 × 2004:3 (131 × 98)3:2 (224 × 150)8 (radius/radius-2)8 horizontal, 6 vertical| ROLE | TOKEN | DEFAULT | LOADING |
|---|---|---|---|
| Surface | ad-space/color/surface | #FFFFFF | #EEF2F9 |
| Caption | ad-space/color/caption | #2340A9 | — |
| Image placeholder | ad-space/color/loading-skeleton | #E6E1EF | #EEF2F9 |
Secondary/Bold/CaptionBarkAda Semibold · 12 / 16 · 01 (sm) · 2 (md)truncate with ellipsisAsyncImage in EBAdSpaceAsyncImage in composablecaption: String?18563:9808Full-width hero banners for home and category surfaces. hero-sm fits a narrow (296) column; hero-md fits standard 336 content width and is the canonical item inside a DS Carousel for multi-ad rails.
296 × 174336 × 17417:1015:812 (radius/radius-3)12 horizontal, 8 verticalDS Carousel container| ROLE | TOKEN | DEFAULT | LOADING |
|---|---|---|---|
| Surface | ad-space/color/surface | #FFFFFF | #EEF2F9 |
| Caption (overlay) | ad-space/color/caption | #FFFFFF | — |
| Caption scrim | overlay/scrim-bottom | #040506 0→40% | — |
| Image placeholder | ad-space/color/loading-skeleton | #E6E1EF | #EEF2F9 |
Primary/Headlines/BlockProxima Soft Bold · 16 / 20 · +0.252Overlay on lower thirdAsyncImage in EBAdSpaceAsyncImage in composable<Carousel><AdSpace size="hero-md"/>…</Carousel>.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") .package(url: "https://github.com/googleads/swift-package-manager-google-mobile-ads", from: "11.0.0") import EBDesignSystem import GoogleMobileAds
implementation("com.gcash.designsystem:eb-components:1.0.0") implementation("com.google.android.gms:play-services-ads:23.0.0") import com.gcash.designsystem.components.EBAdSpace import com.google.android.gms.ads.AdView
| Figma | SwiftUI | Compose | Notes |
|---|---|---|---|
size: banner-sm | banner-lg | banner-mrec | promo-sm | promo-md | hero-sm | hero-md | size: EBAdSpaceSize | size: EBAdSpaceSize | Single enum, grouped into 3 families at the type level. |
isLoading: Boolean | isLoading: Bool | isLoading: Boolean | Drives skeleton treatment regardless of family. |
content: Frame (slot) | content: () -> AnyView | content: @Composable () -> Unit | Accepts AdMob view (banner), image (promo/hero), or illustration. |
caption: String? | caption: String? | caption: String? | Optional. Ignored for banner-*. |
| (not modeled) | onImpression: (() -> Void)? | onImpression: (() -> Unit)? | Fires when 50% visible for ≥1s. Telemetry hook. |
| (not modeled) | onTap: (() -> Void)? | onClick: (() -> Unit)? | Tap callback. AdMob handles its own for banner-*. |
ios/Components/AdSpace/EBAdSpace.swiftios/Components/AdSpace/EBAdSpaceSize.swiftios/Components/AdSpace/EBAdSpaceBanner.swift— wrapsGADBannerViewandroid/components/adspace/EBAdSpace.ktandroid/components/adspace/EBAdSpaceSize.ktandroid/components/adspace/EBAdSpaceBanner.kt— wrapsAdView
// IAB Mobile Banner (320×50) on a receipt EBAdSpace(size: .bannerSm) { GADBannerViewRepresentable(adUnitID: "ca-app-pub-…/receipt-bottom") } // MREC (300×250) inline EBAdSpace(size: .bannerMrec) { GADBannerViewRepresentable(adUnitID: "ca-app-pub-…/inline-mrec") }
// IAB Mobile Banner (320×50) on a receipt EBAdSpace(size = EBAdSpaceSize.BannerSm) { AndroidView(factory = { ctx -> AdView(ctx).apply { adUnitId = "ca-app-pub-…/receipt-bottom" setAdSize(AdSize.BANNER) loadAd(AdRequest.Builder().build()) } }) }
// Dashboard tile, 131×126 EBAdSpace( size: .promoSm, caption: "Send money free", onTap: { openPromo(promo) } ) { AsyncImage(url: promo.coverURL) } // Larger dashboard tile, 224×200 EBAdSpace( size: .promoMd, caption: "Earn up to 5% on savings", onTap: { openPromo(promo) } ) { AsyncImage(url: promo.coverURL) }
EBAdSpace( size = EBAdSpaceSize.PromoMd, caption = "Earn up to 5% on savings", onClick = { openPromo(promo) } ) { AsyncImage( model = promo.coverURL, contentDescription = null ) }
// Single hero EBAdSpace( size: .heroMd, caption: "Weekend deals are here", onTap: { openHero(hero) } ) { AsyncImage(url: hero.coverURL) } // Multi-ad rail — compose inside the DS Carousel EBCarousel { ForEach(heroes) { hero in EBAdSpace(size: .heroMd, caption: hero.title) { AsyncImage(url: hero.coverURL) } } }
// Multi-ad rail — compose inside the DS Carousel EBCarousel { heroes.forEach { hero -> EBAdSpace( size = EBAdSpaceSize.HeroMd, caption = hero.title, onClick = { openHero(hero) } ) { AsyncImage( model = hero.coverURL, contentDescription = null ) } } }
| Requirement | iOS | Android |
|---|---|---|
| Ad disclosure | Add .accessibilityHint("Advertisement") so VoiceOver announces the trait alongside the caption. | Set Modifier.semantics { contentDescription="Advertisement, ${caption}" }. |
| Ad as a single button | Whole surface wrapped in Button { onTap() } with accessibilityElement(children: .combine). | Modifier.clickable { onClick() }.semantics(mergeDescendants=true). |
| Image alt | Banner content is decorative from a11y's perspective — caption carries the meaning. | contentDescription=null on the inner image; caption carries meaning. |
| Min touch target | All seven sizes exceed 44 pt height ✓ | All seven sizes exceed 48 dp height ✓ |
| Loading state | accessibilityLabel("Loading advertisement") on the skeleton; suppress individual shimmer announcements. | contentDescription="Loading advertisement" on the skeleton container. |
| Focus ring | .focused() → 2 px outline using border/focus token for iPad keyboard nav. | Modifier.focusable() + border in border/focus. |
| AdMob compliance | Google Mobile Ads SDK handles its own a11y metadata for banner-* — do not override. | Google Mobile Ads SDK handles its own a11y metadata for banner-* — do not override. |
- Use
banner-*for AdMob-served placements andpromo-*/hero-*for product-owned creative. - Show
isLoading=truewhile the ad SDK or image is fetching — never a blank surface. - Wrap multiple
hero-mds in the DSEBCarouselfor a rail — one Ad Space per card. - Pass real imagery through the
contentslot; ship assets from the product layer. - Fire
onImpressionfor product-owned placements so analytics matches AdMob's own tracking.
- Don't create a dedicated "Ad Carousel" component — compose inside
EBCarouselinstead. - Don't ship placeholder imagery from the DS — the
contentslot is the only source of media. - Don't use
banner-*for product creative — it reserves AdMob dimensions and chrome expectations. - Don't add a caption to
banner-*— AdMob renders its own chrome inside the creative. - Don't override AdMob's own tap handling on
banner-*; the SDK drives navigation.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Single Ad Space component, semantic frame naming (surface, content, caption). No legacy "Group"/"Large"/"hifi"/"midfi" baggage. |
| C2 | Variant & Property Naming | Ready | One size enum, one isLoading boolean, one caption string. Content and state axes are orthogonal. |
| C3 | Token Coverage | Ready | Proposes a main/ad-space/color/* namespace (surface, caption, loading-skeleton) plus radius/radius-1|2|3 per family. All typography bound to DS text styles. |
| C4 | Native Mappability | Ready | banner-* maps 1:1 to GADBannerView / AdView. promo-* / hero-* map to EBAdSpace (ZStack/Box image + optional caption). Carousel composition reuses EBCarousel. |
| C5 | Interaction State Coverage | Ready | Default, pressed, focused, disabled, and loading states all modeled. Loading is an orthogonal boolean, not a variant. |
| C6 | Asset & Icon Quality | Ready | No DS-side assets. Content flows through the slot; placeholder libraries are retired. |
| C7 | Code Connect Linkability | Ready | Clean single-component mapping: EBAdSpace with enum size, boolean isLoading, slot content, string caption. 1:1 param mapping to both platforms. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Ready | Clean: size (7), isLoading (bool), caption (string), content (slot). |
| Token coverage | Ready | main/ad-space/color/* namespace proposed; all colors and radii bound. |
| State coverage | Ready | Default + loading modeled; pressed/focused handled at the native layer. |
| Native component file | Pending | EBAdSpace.swift / EBAdSpace.kt not yet published — Planned API. |
| AdMob SDK wiring | Ready | Google Mobile Ads SPM + Gradle dependencies documented in Installation. |
One size enum with 7 values across 3 size families. isLoading is an orthogonal boolean, not a variant (would otherwise double the count to 14). caption is optional input, not a variant.
| Family | Size value | Dimensions | Aspect | Corner radius | Content slot |
|---|---|---|---|---|---|
| banner | banner-sm | 320 × 50 | 32:5 | 4 | AdMob · IAB Mobile Banner |
| banner | banner-lg | 320 × 100 | 16:5 | 4 | AdMob · IAB Large Banner |
| banner | banner-mrec | 300 × 250 | 6:5 | 4 | AdMob · IAB MREC |
| promo | promo-sm | 131 × 126 | 4:3 (image) | 8 | Image + 1-line caption |
| promo | promo-md | 224 × 200 | 3:2 (image) | 8 | Image + up to 2-line caption |
| hero | hero-sm | 296 × 174 | 17:10 | 12 | Image + optional caption overlay |
| hero | hero-md | 336 × 174 | 15:8 | 12 | Image + caption; default Carousel item |
Ad Space with a 7-value size enum grouped into three families: banner (IAB / AdMob), promo (dashboard tile), hero (full-width). Initialtype=hifi | midfi was a placeholder-authoring crutch, not a runtime variant. Replaced with an orthogonal isLoading boolean. Content flows through the content slot regardless of state. Resolvedcarousel=yes pseudo-variant retired — The legacy "carousel preview" variant was a static 3-card Figma layout, not a runtime carousel. Multi-ad rails are now authored by composing multiple AdSpace size="hero-md" instances inside the DS EBCarousel. Resolvedcontent slot; product teams provide imagery from their own asset pipeline. Resolvedbanner-* sizes map to GADBannerView / AdView. promo-* / hero-* map to a single EBAdSpace view/composable. Clean enum, boolean, string, slot signatures ready for Code Connect CLI registration. Resolvedmain/ad-space/color/surface, main/ad-space/color/caption, main/ad-space/color/loading-skeleton. Replaces ad-hoc use of generic surface tokens across the legacy trio. InitialonImpression (50% visible ≥1s) and onTap. AdMob-backed banner-* inherits the SDK's tracking; product-owned promo-* / hero-* wire consumer analytics via the callbacks. InitialAn inline notification primitive. 20 variants spanning Type (Default / Information / Warning / Error / Success) × Full Width × Left Icon × Right Icon × Description. Today the Full Width boolean actually switches between two structurally different layouts: a flat inline banner (full-width) and a bordered accent card with a "Learn More" action (non-full-width).
style=banner | card (or split into two components) so the two layouts aren't hidden behind a fullWidth boolean. Add a dismiss contract while you're in there.Alerts sit inline in forms, payment flows, and detail screens to communicate status, validation, or supplementary guidance. The accent-card style is often used for onboarding tips; the banner style is used for transient validation.
Type into the title and description to stress-test the layout. Flip Full Width to see the two layouts — the non-full-width "card" style always ships a Learn More action and a right icon (it's really a different component dressed up as a variant, per Open Issues).
yes/no strings with inconsistent casing (No vs no). Type=Default mixes a neutral appearance into an otherwise-semantic set.| Behavior | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Show / hide | Yes | Yes | Not modeled | Alerts fade/slide in on mount. Host-screen concern, not component state. |
| Action tap (Learn More) | Drawn | Drawn | No button | Learn More text + chevron is a drawn element — no pressed state. Should be a Text Button instance. |
| Dismiss (X close) | Missing | Missing | Not built | Dismissable alerts need an X button + onDismiss callback. Not modeled today. |
| A11y announcement | Implicit | Implicit | Not annotated | Error alerts should announce as role="alert" / LiveRegion.Assertive. Informational use role="status" / LiveRegion.Polite. |
- Boolean properties use
yes/nostrings.Full Width,Left Icon,Right Icon,Description— andFull Widthhas inconsistent casing (Noon the non-full-width Information variant,noelsewhere). Blocks direct SwiftBool/ KotlinBooleanmapping. C2 · Variant & Property Naming - Two layouts hidden behind
Full Width. Non-full-width variants are accent cards (left border + Learn More action); full-width variants are flat banners. The axis name describes the width, not the real structural difference. Either split into two components or rename tostyle=banner | card. C1 · Layer Structure & Naming Type=Defaultmixes with semantic types. Default is a neutral appearance; Information / Warning / Error / Success are semantic statuses. Mixing them in one enum blurs the mental model. Rename toNeutralor put it on a separate axis. C2 · Variant & Property Naming- Left-icon slot is a placeholder circle. 24 × 24 gray
icon-placeholder— not swappable via instance-swap. Consumers can't drop in a real Icon from the DS icon library. C6 · Asset & Icon Quality - No dismiss / close state. Dismissable alerts are a standard pattern (X button on the right,
onDismisscallback). Not modeled in any of the 20 variants. C5 · Interaction State Coverage - Learn More action is drawn in-place. Text + chevron aren't a real Text Button instance — no pressed or disabled state coverage, can't swap in "Try again", "View details", etc. without editing the master. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked until schema cleanup and slot adoption land. C7 · Code Connect Linkability
- Normalize boolean values and casing.
Full Width/Left Icon/Right Icon/Description→true/false. Eliminates theNo/nocasing bug. Rename - Expose
style=banner | card. Makes the real difference explicit:cardhas the left-border accent + action link,banneris the flat inline surface. Either this, or split into two components (AlertBanner+AlertCard). Property - Rename
Type=DefaulttoNeutral. Disambiguates from "the default / unset value" and matches the semantic naming of its siblings. Rename - Replace the left-icon placeholder with a swappable Icon slot. Adopt Figma Slots so product teams can drop in any Icon from the DS library without editing the master. Slot
- Add a dismissable variant with an X icon on the right and an
onDismisscallback contract. Pairs with a default timeout for transient alerts. State - Promote the Learn More action to a Text Button slot. Gains pressed / disabled states automatically, and lets consumers swap copy ("View details", "Try again", "Undo") without editing the master. Same pattern Button will have once trailing slots ship. Composition
- Document the A11y live-region mapping. Error alerts should announce as assertive; informational alerts as polite. Spell this out in the component spec so engineers wire the right roles. A11y
18444:2087Flat inline banner. 360 wide, 12 × 16 padding, 4 px radius, soft shadow. Optional left icon, optional right icon, optional description.
Informationyesnoyesyes| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface | info/bg | #E5F1FF |
| Title | info/label-title | #072592 |
| Description | info/description | #072592 @ 80% |
| Icon | info/icon | #2340A9 |
36012 × 1612 × 1640 1 3 rgba(232,238,242,.79)824 × 24Primary/Multi-line Label/BaseHeyMeow Rnd Bold16 / 20 · +0.25Secondary/Bold/CaptionBarkAda Semibold12 / 18 · +018444:2019Card with a 6 px left-border accent. Always ships a right icon + Learn More action + description. Title is larger (18 / 23) than the banner.
InformationNonoyesyes| ROLE | TOKEN | VALUE |
|---|---|---|
| Left-border accent | info/border | #005CE5 |
| Link label | info/label-link | #072592 |
3604 16 16 206 px solid accent432 × 3216 × 16Primary/Headlines/Block18 / 23 · +0.2512 / 18 · +0All five types share the same token structure: main/alert/color/{type}/{bg, border, label-title, description, icon, label-link}. The values below document the full palette.
| TYPE | BG | BORDER | TITLE | DESCRIPTION | ICON |
|---|---|---|---|---|---|
| Default (Neutral) | #F6F9FD | — | #0A2757 | #6780A9 | — |
| Information | #E5F1FF | #005CE5 | #072592 | #072592 @ 80% | #2340A9 |
| Warning | #FFF9EB | #EBB30A | #6C5009 | #966F0B | #966F0B |
| Error | #F8E6E6 | #D61B2C | #D61B2C | #D61B2C | #B50707 |
| Success | #E7F8F0 | #27C990 | #035E50 | #035E50 | #035E50 |
.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBAlert
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
Type: Default | Information | Warning | Error | Success | type: neutral | information | warning | error | success | type: EBAlertType | type: EBAlertType |
Full Width: yes | No | style: banner | card | .ebAlertStyle(.banner) modifier | style: EBAlertStyle |
Left Icon: yes | no | leadingIcon?: Icon (slot) | leadingIcon: Image? | leadingIcon: @Composable (() -> Unit)? |
Right Icon: yes | no | trailingIcon?: Icon (slot, auto for semantic types) | trailingIcon: Image? | trailingIcon: @Composable (() -> Unit)? |
Description: yes | no | description?: String | description: String? | description: String? |
| (implicit) | title: String | title: String | title: String |
| (not modeled) | action?: TextButton (card only) | action: EBTextButton? | action: @Composable (() -> Unit)? |
| (not modeled) | onDismiss?: () -> Void | onDismiss: (() -> Void)? | onDismiss: (() -> Unit)? |
ios/Components/Alert/EBAlert.swiftandroid/components/alert/EBAlert.kt
// Inline banner — most common form EBAlert( type: .information, title: "Your ID is being reviewed", description: "We'll notify you within 24 hours.", trailingIcon: Image("info.circle") ) .ebAlertStyle(.banner) // Accent card — with action EBAlert( type: .warning, title: "Transaction limit approaching", description: "You've used 80% of this month's ₱50,000 limit.", action: EBTextButton("Learn more", trailing: .chevron) ) .ebAlertStyle(.card) // Dismissable EBAlert( type: .success, title: "Transfer completed", onDismiss: { showAlert = false } )
// Inline banner — most common form EBAlert( type = EBAlertType.Information, title = "Your ID is being reviewed", description = "We'll notify you within 24 hours.", trailingIcon = { Icon(EBIcons.Info, contentDescription = null) }, style = EBAlertStyle.Banner ) // Accent card — with action EBAlert( type = EBAlertType.Warning, title = "Transaction limit approaching", description = "You've used 80% of this month's ₱50,000 limit.", action = { EBTextButton("Learn more", trailingIcon = EBIcons.ChevronRight, onClick = onLearnMore) }, style = EBAlertStyle.Card ) // Dismissable EBAlert( type = EBAlertType.Success, title = "Transfer completed", onDismiss = { showAlert = false } )
| Requirement | iOS | Android |
|---|---|---|
| Live region — error | Post UIAccessibility.Notification.announcement with .high priority on mount. | Modifier.semantics { liveRegion=LiveRegionMode.Assertive } on the container. |
| Live region — info / success | Post announcement with default priority. | LiveRegionMode.Polite on the container. |
| Action label | Text Button inside action slot owns its own label + hint. | Text Button inside action slot owns its own contentDescription. |
| Dismiss button | Icon Button with accessibilityLabel: "Dismiss". | Icon Button with contentDescription="Dismiss". |
| Color contrast | All title/description colors on their type-surface tested to ≥4.5:1. Verified in variable defs. | Same ratios apply. |
- Use inline banners for transient feedback (validation errors, success confirmations).
- Use accent cards for persistent guidance (onboarding tips, limit warnings).
- Pair an error alert with focus management — move keyboard focus to the alert on mount.
- Keep copy short: title is one sentence; description is 1–2 lines.
- Don't stack more than one alert per section — consolidate or prioritize.
- Don't use alerts for in-line field validation — use the field's error state.
- Don't mix semantic types in the same screen region (e.g. info + warning side-by-side) — it dilutes meaning.
- Don't draw a "dismiss" X manually — use the dismissable variant (once added).
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Needs Refinement | Two layouts (banner + card) hidden behind Full Width — rename to style or split. |
| C2 | Variant & Property Naming | Rework | Booleans yes/no with inconsistent casing; Type=Default mixes with semantic types. |
| C3 | Token Coverage | Ready | All 5 types fully tokenized under main/alert/color/*. |
| C4 | Native Mappability | Needs Refinement | Maps cleanly to a custom EBAlert view / composable once schema cleans up. |
| C5 | Interaction State Coverage | Needs Refinement | No dismiss state; Learn More isn't a real button — no pressed coverage. |
| C6 | Asset & Icon Quality | Needs Refinement | Left-icon slot is a placeholder circle — adopt a Figma Slot. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until schema cleanup + slot adoption land. |
Type (5) × layout combos=20 built variants out of 24 × 5=80 theoretical combinations.
| Group | Count | Axes |
|---|---|---|
| Accent card | 4 | fullWidth=No, L=no, R=yes, desc=yes · Information / Warning / Error / Success |
| Banner — right icon, desc | 5 | fullWidth=yes, L=no, R=yes, desc=yes · all 5 types |
| Banner — left icon, desc | 5 | fullWidth=yes, L=yes, R=no, desc=yes · all 5 types |
| Banner — left icon, no desc | 5 | fullWidth=yes, L=yes, R=no, desc=no · all 5 types |
| Default — full width, no icons, with desc | 1 | fullWidth=yes, L=no, R=no, desc=yes · Default only |
View full breakdown (20 rows)
| # | Node | Type | Full Width | Left Icon | Right Icon | Description | Dimensions |
|---|---|---|---|---|---|---|---|
| 1 | 18444:2013 | Default | yes | no | no | yes | 360 × 86 |
| 2 | 18444:2019 | Information | No | no | yes | yes | 360 × 138 |
| 3 | 18444:2033 | Warning | No | no | yes | yes | 360 × 138 |
| 4 | 18444:2047 | Error | No | no | yes | yes | 360 × 138 |
| 5 | 18444:2065 | Success | No | no | yes | yes | 360 × 138 |
| 6 | 18444:2083 | Default | yes | no | no | no | 360 × 48 |
| 7 | 18444:2087 | Information | yes | no | yes | yes | 360 × 60 |
| 8 | 18444:2096 | Warning | yes | no | yes | yes | 360 × 60 |
| 9 | 18444:2105 | Error | yes | no | yes | yes | 360 × 60 |
| 10 | 18444:2118 | Success | yes | no | yes | yes | 360 × 60 |
| 11 | 18444:2131 | Default | yes | yes | no | yes | 360 × 84 |
| 12 | 18444:2138 | Information | yes | yes | no | yes | 360 × 84 |
| 13 | 18444:2145 | Warning | yes | yes | no | yes | 360 × 84 |
| 14 | 18444:2152 | Error | yes | yes | no | yes | 360 × 84 |
| 15 | 18444:2159 | Success | yes | yes | no | yes | 360 × 84 |
| 16 | 18444:2166 | Default | yes | yes | no | no | 360 × 48 |
| 17 | 18444:2171 | Information | yes | yes | no | no | 360 × 48 |
| 18 | 18444:2176 | Warning | yes | yes | no | no | 360 × 48 |
| 19 | 18444:2181 | Error | yes | yes | no | no | 360 × 48 |
| 20 | 18444:2186 | Success | yes | yes | no | no | 360 × 48 |
yes/no with inconsistent casing; Type=Default mixes with semantic types. OpenfullWidth. Rename to style or split. Openicon-placeholder circle; adopt Figma Slots. OpenA centered, display-style numeric entry for PHP amounts in Send Money, Cash-In, and top-up flows. 12 variants across size (Default / Large) × state (Default / Filled / Error) × label (yes / no). The Default size prefixes a Peso Sign (₱) glyph; the Large size is a 53px headline with no glyph. The field is a single underline — not the bordered input-box pattern used by Input Field and its siblings.
label=yes/no needs Boolean naming (C2). Decide whether to keep as a standalone sibling or fold into Input Field as type: .currency.Contexts are illustrative. Final screens will reference actual GCash patterns (Send Money, Cash-In, Top-up).
Toggle size, state, and label to see the amount field update in real time.
label=yes/no instead of true/false — doesn't match isFilled=true/false on sibling fields. State set also differs from Input Field's 4-state model (Default/Active/Error/Disabled).| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default (empty) | Yes | Yes | state=Default | Shows 0.00 in placeholder color #90A8D0. Border #ADBDDC. |
| Filled | Yes | Yes | state=Filled | Amount typed; navy #0A2757 text, border darkens to #445C85. |
| Error | Yes | Yes | state=Error | Red #D61B2C for amount, border, and subtext. Subtext becomes the validation message. |
| Active (focused) | No | No | — | Missing variant. Native field will show caret + keyboard; no DS-defined visual affordance. |
| Disabled | No | No | — | Missing variant. Top-up confirmations and locked amounts have no canonical appearance. |
- None yet — initial assessment.
- Peso Sign glyph is a raster image. The ₱ mark renders via an
<img>reference (imgShapeFull) rather than an instance of thePeso Sign - Proximavector icon. Raster assets can't be recolored with tokens, scale poorly on high-DPI screens, and block Code Connect asset mapping. C6 · Asset & Icon Quality - Missing Active (focused) state. The component exposes only Default / Filled / Error. Native keyboards surface a caret on focus, but the DS has no defined focused-border or amount color — devs have to invent one per flow. C5 · Interaction State Coverage
- Missing Disabled state. Amount confirmations, locked top-up amounts, and review screens use an inert version of this field. Without a
state=Disabledvariant there's no single source of truth for its appearance. C5 · Interaction State Coverage labelproperty usesyes/noinstead oftrue/false. Boolean naming.label=Yesdoesn't map cleanly to SwiftBool/ KotlinBoolean, and it diverges from sibling fields that were already migrated totrue/false(Input Field'sisFilled). Should also be renamed tohasLabelorshowLabel. C2 · Variant & Property Naming- Large variant drops the peso glyph. When
size=Largethe ₱ is removed entirely — the amount is just500.00. If this is intentional it's under-documented; if not, it's an inconsistency that users and devs will stumble on. No property controls it, so it can't be opted into. C2 · Variant & Property Naming - Code Connect mappings not registered. Blocked by the structural issues above — register after peso glyph is vectorized, states are added, and
labelis migrated to Boolean. C7 · Code Connect Linkability
- Replace the raster peso with a vector instance of
Peso Sign - Proxima. That library component already exists and is sized per font tier — swap the current<img>for an instance-swap slot so color can bind tomain/amount-text-field/{state}/icon-currencyand scale at any DPR. Asset - Add
state=Activeandstate=Disabledvariants. Extends the enum to the same 4-state model used by Input Field (Default / Active / Error / Disabled) plus aFilleddisplay-only state, or collapseFilledinto a derived-from-content view. Either way, lock the state axis to match siblings. State - Migrate
label=yes/noto a BooleanshowLabel=true/false. Matches the canonical naming used by Input Field'sisFilledand unlocks direct Code Connect mapping to SwiftBool/ KotlinBoolean. Consider splittingsubtextout as its own Boolean so error-copy and helper-copy can be toggled independently of label. Rename - Expose leading and trailing slots for the currency mark and unit suffix. Hard-coding the peso glyph ties the component to PHP. A
leadingCurrencyslot (₱, $, €) and an optionaltrailingUnitslot ("PHP") future-proofs the component for multi-currency flows without a per-country fork. Slot - Family decision — fold into Input Field as
type: .currency, or keep as EBAmountTextField sibling. Option A: Keep separate — Amount Text Field stays a display-style sibling of Input Field with its own underline anatomy; share tokens via a common text-field token tier. Option B (recommended): Fold into Input Field asEBInputField(type: .currency, …), using SwiftUI'sTextField(value:format:.currency(code:))+.keyboardType(.decimalPad)and Compose'sOutlinedTextField(keyboardOptions=KeyboardOptions(keyboardType=KeyboardType.Decimal), leadingIcon={ Text("₱") }). Option B eliminates the duplicate peso glyph instance inside Dropdown's "Amount" variant and the Recipient Field's currency-prefix case. Family - Document locale and keyboard behavior in the component. Decimal separator, thousands separator, minimum/maximum, and zero-padding rules are product concerns today. Adding a short
Docsnote in Figma (or in this assessment's Code tab) gives implementers a single source of truth. Docs
12 variants across 3 axes: size (Default / Large) × state (Default / Filled / Error) × label (yes / no). Default size prefixes the peso glyph + 35px amount; Large size is a 53px headline with no glyph. Both share the single-underline anatomy.
53px amount headline, filled value, dark navy. No peso glyph — the Large tier is used as a hero-amount display.
Empty state at 53px, muted placeholder color. Used before the user types in hero amount screens.
Validation error — amount, border, and subtext all tint red #D61B2C. Subtext is the error message slot.
35px amount with leading peso glyph. Standard send/pay screens. Peso glyph is currently a raster image (see C6 open issue).
Empty state — both peso glyph and 0.00 render in the placeholder tint #90A8D0 / #D7E0EF.
Validation failed — peso glyph, amount, border, and subtext all tint red #D61B2C.
All colors bind to the main/amount-text-field/{state}/{role} token family. No variable modes, so the table is a flat state matrix.
| Role | Token | DEFAULT | FILLED | ERROR |
|---|---|---|---|---|
| Border (underline) | amount-text-field/{state}/border | #ADBDDC | #445C85 | #D61B2C |
| Label (top) | amount-text-field/{state}/label | #0A2757 | #0A2757 | #0A2757 |
| Amount (body) | amount-text-field/{state}/label-amount | #90A8D0 | #0A2757 | #D61B2C |
| Peso glyph | amount-text-field/{state}/icon-currency | #D7E0EF | #0A2757 | #D61B2C |
| Subtext | amount-text-field/{state}/subtext | #0A2757 | #0A2757 | #D61B2C |
Token coverage is complete for the three defined states. Active and Disabled states would need new tokens under the same family (e.g. amount-text-field/active/border, amount-text-field/disabled/*).
| Layer | DS Text Style | Font | Size | Line-height | Tracking |
|---|---|---|---|---|---|
| Label (top) | Primary/Label/Light/Large | Proxima Soft Semibold | 18px | 18px | 0.25 |
| Amount — Large | Primary/Headlines/Epic | Proxima Soft Semibold | 53px | 58px | 0 |
| Amount — Default | Primary/Headlines/Spotlight | Proxima Soft Bold | 35px | 38px | 0 |
| Subtext | Primary/Multi-line Label/Light/Small | Proxima Soft Semibold | 14px | 16px | 0.25 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:form-elements:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.form.* // Compose
Package not yet published. These are the planned distribution paths.
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| size=Large / Default | .controlSize(.large / .regular) | size=EBAmountSize.Large / Default | Large=53px headline only. Default=35px + peso glyph. |
| state=Default | — | — | Empty / placeholder rendering. |
| state=Filled | Derived from value > 0 | Derived from value > 0 | Display-only — the state follows the bound value. |
| state=Error | .ebError(true) | isError=true | Validation failed. |
| label=yes / no | label: String? | label: String? | Pass nil / omit to hide the label row. |
| subtext (copy) | subtext: String? | subtext: String? | Doubles as the error message in state=Error. |
EBAmountTextField(value: $amount, label: "Add Your Label Here", subtext: "Add your subtext here") .keyboardType(.decimalPad)
EBAmountTextField( value = amount, onValueChange = { amount = it }, label = "Add Your Label Here", subtext = "Add your subtext here", keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal) )
EBAmountTextField(value: $amount) .ebAmountSize(.large) .keyboardType(.decimalPad)
EBAmountTextField( value = amount, onValueChange = { amount = it }, size = EBAmountSize.Large, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal) )
EBAmountTextField(value: $amount, subtext: "How much do you want to save?") .ebError(true)
EBAmountTextField( value = amount, onValueChange = { amount = it }, subtext = "How much do you want to save?", isError = true )
| Requirement | iOS | Android |
|---|---|---|
| Keyboard type | .keyboardType(.decimalPad) | KeyboardType.Decimal |
| Currency format | .currency(code: "PHP") | VisualTransformation + NumberFormat.getCurrencyInstance() |
| Accessibility label | .accessibilityLabel("Amount in pesos") | contentDescription="Amount in pesos" |
| Error announcement | VoiceOver reads error via .accessibilityValue | TalkBack reads error via semantics { error() } |
| Minimum touch target | 44 x 44 pt | 48 x 48 dp |
Do
Use .decimalPad / KeyboardType.Decimal so users can enter fractional pesos without switching keyboards.
Don't
Use Amount Text Field for phone numbers, account numbers, or non-currency numerics — it hard-codes the peso glyph and currency formatting.
Do
Pair Large size with a label above and a hint subtext below for hero entry screens (Send Money, Cash-In).
Don't
Drop the peso glyph on Default size to "save space" — the glyph is the primary signal that the input expects currency.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic names: peso-offset, input, Peso Sign - Proxima. No Frame 42 artifacts. |
| C2 | Variant & Property Naming | Rework | label=yes/no should be Boolean showLabel=true/false. Large variant's missing peso glyph isn't gated by a property. |
| C3 | Token Coverage | Ready | All colors bind to main/amount-text-field/{state}/{role}. Typography uses DS text styles. |
| C4 | Native Mappability | Partial | Maps to TextField + .keyboardType(.decimalPad) / OutlinedTextField + KeyboardType.Decimal. Display-style underline anatomy needs custom styling vs default framework chrome. |
| C5 | Interaction State Coverage | Rework | Missing Active (focused) and Disabled states. Only Default / Filled / Error defined. |
| C6 | Asset & Icon Quality | Rework | Peso Sign glyph is a raster <img> reference, not a vector instance. Can't be tint-bound to the icon-currency token. |
| C7 | Code Connect Linkability | Pending | No CLI mappings registered. Blocked by C2, C5, C6. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Rework | label=yes/no blocks Boolean mapping |
| State coverage | Rework | Missing Active / Disabled |
| Asset linkability | Rework | Raster peso glyph not mappable to a vector asset param |
| Native component file | Pending | EBAmountTextField.swift / EBAmountTextField.kt not yet created |
2 size × 3 state × 2 label=12 variants.
| size | state | label | Node ID |
|---|---|---|---|
| Large | Filled | yes | 152:48113 |
| Large | Default | yes | 152:48116 |
| Large | Error | yes | 152:48120 |
| Large | Filled | no | 152:48111 |
| Large | Default | no | 152:48115 |
| Large | Error | no | 152:48110 |
| Default | Filled | yes | 152:48121 |
| Default | Default | yes | 152:48114 |
| Default | Error | yes | 152:48118 |
| Default | Filled | no | 152:48117 |
| Default | Default | no | 152:48119 |
| Default | Error | no | 152:48112 |
<img src={imgShapeFull}> rather than a vector instance of Peso Sign - Proxima. Blocks tint-color binding and Code Connect asset mapping. Openlabel=yes/no instead of Boolean showLabel=true/false. Incompatible with Swift Bool / Kotlin Boolean for Code Connect mapping. OpenStacked/overlapping avatars for participant lists — conversation members, shared documents, collaboration indicators. 4 variants: layout=pair (2), layout=trio (3), layout=quad (4), and layout=overflow (3 + "+N" badge). Fixed 48×48 container with 24×24 inner avatars.
layout with semantic values ✓. Overflow variant added ✓. Inner avatars repointed to canonical Avatar via instance swap ✓. Only C7 (Code Connect) remains — tracked universally across all components.How the avatar group appears in a real product screen — conversation list where grouped chats display stacked avatars (DX Team, David's Surprise Party) alongside single-avatar threads.
Toggle count to see the avatar group update in real time.
no. of initals → layout with semantic values (pair/trio/quad/overflow). C2 resolved. Inherits the main/avatar/brand/intials typo from Avatar's shared variable collection (tracked under Avatar, not an Avatar Group blocker).17143:4488). Changes to Avatar now propagate to Avatar Group automatically. Compositional inheritance restored.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| 2 avatars | Yes | Yes | layout=pair | Diagonal overlap — top-left + bottom-right |
| 3 avatars | Yes | Yes | layout=trio | Triangle arrangement |
| 4 avatars | Yes | Yes | layout=quad | 2×2 grid |
| Overflow (5+) | Yes | Yes | layout=overflow | 3 avatars + "+N" badge in bottom-right slot. Uses the same default/light style as the avatar it replaces. |
| Pressed / Disabled | N/A | N/A | — | Display-only. Tap behavior handled by parent container. |
- Property renamed:
no. of initals→layoutwith semantic values (pair/trio/quad/overflow). Fixes typo, removes spaces/dots, replaces pseudo-numeric strings with true enum values. Maps cleanly to SwiftUIEBAvatarGroupLayout.pair/.trio/.quad/.overflow/ ComposeEBAvatarGroupLayout.Pairetc. C2 Fixed - Overflow variant
layout=overflowadded — bottom-right slot shows "+N" badge instead of a 4th avatar. Handles groups larger than 4. C5 Fixed - Inner avatars repointed via instance swap to the canonical Avatar component (
17143:4488). Previously referenced a duplicate Avatar at21:94766— now all 4 variants inherit from the canonical source. Compositional pattern restored: changes to Avatar will propagate here automatically. C6 Fixed
- Code Connect mappings not registered. Structural work (count → layout rename, overflow variant, instance swap to canonical Avatar) is complete — registration can proceed. C7 · Code Connect Linkability
- Add size variants. Current 48×48 container is fixed — bigger groups (5+ avatars) benefit from a larger container for readability. Propose
groupSize=small | medium | largewith appropriate inner avatar sizes. Property - Deprecate the duplicate Avatar at
21:94766. Now that Avatar Group points at the canonical17143:4488, the duplicate should be marked deprecated and removed in a future DS cleanup pass. Family
4 variants (pair / trio / quad / overflow). All use a 48×48 fixed container with 24×24 inner avatars arranged with intentional overlap to indicate grouping. Overflow replaces the 4th avatar with a "+N" badge for groups larger than 4.
Two avatars placed diagonally. Top-left uses dark-initials (brand), bottom-right uses initials-light (default).
Three avatars in a triangular arrangement. Two on top (dark + dark), one default at bottom.
Four avatars in a 2×2 grid. Top row: brand + brand. Bottom row: default + default.
Overflow variant — 3 avatars plus a "+N" badge in the bottom-right position. Use when group has 5 or more members. The "+N" uses the default/light avatar style with overridable text content.
Inner avatars use the same tokens as the main Avatar component. See Avatar / Style tab for the full token reference.
| Role | Token | Value |
|---|---|---|
| Brand avatar bg | main/avatar/brand/bg | #005CE5 |
| Brand avatar border | main/avatar/brand/border | #E5EBF4 |
| Brand avatar initials | main/avatar/brand/intials library typo | #FFFFFF |
| Default avatar bg | main/avatar/default/bg | #F6F9FD |
| Default avatar border | main/avatar/default/border | #E5EBF4 |
| Default avatar initials | main/avatar/default/initials | #2340A9 |
| Property | Value |
|---|---|
| Container size | 48 × 48 |
| Inner avatar size | 24 × 24 |
| Inner avatar radius | 12px |
| Inner avatar border | 1.5px solid |
| Overlap offset (2 avatars) | 16px diagonal |
| Overlap offset (3 avatars) | 12px horizontal, 24px vertical |
| Overlap offset (4 avatars) | 24px grid step |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:avatar:1.0.0") }
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| layout=pair | .ebLayout(.pair) | layout=EBAvatarGroupLayout.Pair | 2 avatars, diagonal overlap |
| layout=trio | .ebLayout(.trio) | layout=EBAvatarGroupLayout.Trio | 3 avatars, triangle |
| layout=quad | .ebLayout(.quad) | layout=EBAvatarGroupLayout.Quad | 4 avatars, 2×2 grid |
| layout=overflow | .ebLayout(.overflow) | layout=EBAvatarGroupLayout.Overflow | 3 avatars + "+N" badge |
// Pair / trio / quad — pass avatars, layout auto-detected from count EBAvatarGroup(avatars: [ EBAvatar(initials: "DM"), EBAvatar(initials: "LM"), EBAvatar(initials: "AB") ]) // Overflow — pass full list + max visible count EBAvatarGroup(avatars: allAvatars, maxVisible: 3) .ebLayout(.overflow) // renders 3 avatars + "+N" if allAvatars.count > 3
// Pair / trio / quad — pass avatars, layout auto-detected from count EBAvatarGroup( avatars = listOf( Avatar(initials = "DM"), Avatar(initials = "LM"), Avatar(initials = "AB") ) ) // Overflow — pass full list + maxVisible EBAvatarGroup( avatars = allAvatars, maxVisible = 3, layout = EBAvatarGroupLayout.Overflow )
| Requirement | iOS | Android |
|---|---|---|
| Accessibility label | .accessibilityLabel("3 participants: Dara, Lara, Alex") | contentDescription="3 participants: ..." |
| Role | Decorative if not tappable — use .accessibilityHidden(true) on individual avatars | Same — prefer single group-level semantic |
| Tap target | 48 × 48 container meets iOS HIG when whole group is tappable | Meets Material 48dp minimum |
Do
Use Avatar Group for 2–4 participants in a list item, header, or shared-with indicator.
Don't
Use for counts above 4 without an overflow "+N" badge — users can't infer total count from a cluster alone.
Do
Provide a single group-level accessibility label listing all participants.
Don't
Let each avatar announce separately — creates VoiceOver/TalkBack noise.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic names: Avatar, container. Top-level component set uses "Avatar Group" — clean. |
| C2 | Variant & Property Naming | Needs Fix | Property no. of initals has typo, spaces, and uses string values. Rename to count with integer values. |
| C3 | Token Coverage | Ready | All colors bound to Avatar's tokens. Inherits the same typo in intials — tracked under Avatar's open issues. |
| C4 | Native Mappability | Partial | Maps to stacked avatars via ZStack (iOS) / Box + offset (Compose). Fixed 48×48 and count=2/3/4 don't match a dynamic native list — API should accept an array. |
| C5 | Interaction State Coverage | Needs Fix | No overflow state for 5+ avatars. Common DS pattern ("+N" badge) is missing. |
| C6 | Asset & Icon Quality | Partial | Inner avatars are hardcoded 24px containers, not Avatar component instances. Breaks compositional inheritance — changes to Avatar won't propagate. |
| C7 | Code Connect Linkability | Pending | No CLI mappings registered yet. |
| layout | Node ID | Size | Notes |
|---|---|---|---|
| pair | 18276:4555 | 48 × 48 | 2 avatars — diagonal |
| trio | 18276:4558 | 48 × 48 | 3 avatars — triangle |
| quad | 18276:4562 | 48 × 48 | 4 avatars — 2×2 grid |
| overflow | 18276:4585 | 48 × 48 | 3 avatars + "+N" overflow badge |
no. of initals → layout. Values changed from pseudo-numeric strings (2/3/4/5+) to semantic enum values (pair/trio/quad/overflow). Fixes typo, spaces, and dots in one pass. Clean native enum mapping. Fixedlayout=overflow displays 3 avatars + a "+N" badge in the 4th slot. Handles groups larger than 4. Added21:94766. All 4 variants now use instances of the canonical Avatar at 17143:4488. Compositional inheritance restored. Swappedno. of initals: missing second "i", contains dot + space. Values are strings instead of integers. Blocks native enum mapping. Fixed in 1.1.0A circular display element showing user initials or a profile image. Supports 7 sizes (20px-90px) and 3 types (dark initials, light initials, image). Used when a profile image is unavailable or for visual user identification.
How the avatar appears in a real product screen — Contacts list with Favorites row (brand fill + default fill avatars in circular display).
Toggle type and size to see the avatar update in real time.
main/avatar/...). Variant naming verified correct (type=initials-light). Border-radius tokenized to radius/radius-round. One C2 issue remaining: token main/avatar/brand/intials has typo (should be initials) — manual rename needed in Figma Variables panel.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | type + size | Display-only. All 3 types fully defined across 7 sizes. |
| Pressed | N/A | N/A | -- | Display-only component. Tap behavior handled by parent container. |
| Disabled | N/A | N/A | -- | Display-only component. No disabled state. |
| Focused (a11y) | N/A | N/A | -- | Display-only. Focus rings rendered by parent interactive container if needed. |
Avatar is display-only. Interaction states (pressed, disabled, focused) are N/A and handled by parent containers.
- Border-radius: bound to
radius/radius-round(99999) across all sizes — previously hardcoded per size (C3) - Border-width: confirmed fixed per size by design — not a token gap (C3)
- Raster backgrounds replaced with vector ELLIPSE layers across all 5 affected initials variants (C6)
- Avatar Group compound component created (previously a design recommendation) — see sibling component under Avatar group (C2)
- Variant property value naming verified on recheck: variant names are correctly hyphenated as
type=initials-lightin Figma source. Earlier "spaces" report was an MCP output artifact (TypeScript enum generation converts hyphens to spaces). No action required. C2 Verified
- Code Connect mappings not registered. Structural issues are resolved — registration can proceed against the current
type×shape×sizeschema. C7 · Code Connect Linkability
- Token name typo —
intials. The tokenmain/avatar/brand/intialsis missing the second "i" (should beinitials). Lives in the shared Variables collection, not in this component — tracked here for visibility but not counted as an Avatar-level blocker. Fix requires renaming in Figma → Variables panel. C2 Noted
- Add a status
badgeoverlay slot. Common in chat, contacts, and profile lists — online/offline dots, notification counts, verified checkmarks. Today consumers stack a Badge manually on top of Avatar; a built-in slot encodes the correct offset and sizing. Slot
Blue circle with white initials text. Branded avatar used as default when no photo is available.
Display-only component. No interaction states. All colors bound to main/avatar/brand/ tokens.
| Role | Token | Value |
|---|---|---|
| Circle bg | main/avatar/brand/bg | #005CE5 |
| Circle border | main/avatar/brand/border | #E5EBF4 |
| Initials text | main/avatar/brand/initials | #FFFFFF |
Light circle with blue initials text. Neutral variant for non-branded contexts.
Display-only component. No interaction states. All colors bound to main/avatar/default/ tokens.
| Role | Token | Value |
|---|---|---|
| Circle bg | main/avatar/default/bg | #F6F9FD |
| Circle border | main/avatar/default/border | #E5EBF4 |
| Initials text | main/avatar/default/initials | #2340A9 |
User profile photo in a circle clip. Falls back to placeholder when image fails to load.
Display-only component. Placeholder colors shown when image has not loaded. All colors bound to main/avatar/placeholder/ tokens.
| Role | Token | Value |
|---|---|---|
| Placeholder bg | main/avatar/placeholder/bg | #C2CFE5 |
| Placeholder border | main/avatar/placeholder/border | #E5EBF4 |
iOS -- Swift Package Manager
// In Xcode: File -> Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios" // Or in Package.swift: .package( url: "https://github.com/AY-Org/eb-ds-ios", from: "1.0.0" )
Android -- Gradle (Kotlin DSL)
// build.gradle.kts (app) dependencies { implementation("com.eastblue.ds:avatar:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.avatar.* // Compose
Package not yet published. These are the planned distribution paths. API shape is final -- native implementation is pending.
Every row maps a Figma component property to its native equivalent.
| Figma Property | SwiftUI | Compose |
|---|---|---|
type=dark-initials | .darkInitials | AvatarType.DarkInitials |
type=initials-light | .lightInitials | AvatarType.LightInitials |
type=image | .image(url:) | AvatarType.Image(url) |
size=20px...90px | size: AvatarSize | size: AvatarSize |
// Dark initials EBAvatar("DM", type: .darkInitials, size: .large)
// Dark initials EBAvatar( initials = "DM", type = AvatarType.DarkInitials, size = AvatarSize.Large )
// Light initials EBAvatar("LM", type: .lightInitials, size: .medium)
// Light initials EBAvatar( initials = "LM", type = AvatarType.LightInitials, size = AvatarSize.Medium )
// Image EBAvatar(imageURL: profileURL, size: .large)
// Image EBAvatar( imageUrl = profileUrl, type = AvatarType.Image, size = AvatarSize.Large )
| Requirement | iOS | Android |
|---|---|---|
| Alt text | accessibilityLabel("User avatar") | contentDescription="User avatar" |
| Decorative mode | isAccessibilityElement=false (in lists) | importantForAccessibility=no |
| Image loading | AsyncImage with placeholder | SubcomposeAsyncImage with placeholder |
Do
Use dark-initials as default when no photo is available.
Don't
Use image type with placeholder -- use initials instead.
Do
Match avatar size to context (20px in dense lists, 90px in profiles).
Don't
Mix initials types in the same context.
Do
Always pass 2-letter initials (first + last).
Don't
Show single-letter or empty initials.
Do
Provide alt text for image avatars.
Don't
Skip accessibility labels.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Layers named container, background, replace here - image. Simple hierarchy. Minor: some sizes have a child also named container. |
| C2 | Variant & Property Naming | Ready | Variant naming resolved (initials-light). Token name typo fixed (main/avatar/brand/initials). Size values use px suffix (minor, no impact on native mapping). |
| C3 | Token Coverage | Partial | 8 color tokens, full typography tokens, and radius/radius-round connected. Border-width is fixed per size (by design). |
| C4 | Native Mappability | Ready | Maps to custom Circle-clipped view on both platforms. No web-only patterns. |
| C5 | Interaction State Coverage | N/A | Display-only component. No interactive states needed. |
| C6 | Asset & Icon Quality | Ready | All initials variants now use vector ELLIPSE layers. Image type rasters are expected (user photos). |
| C7 | Code Connect Linkability | Needs Refinement | Usage descriptions attached. Variant naming now clean. Token typo remains. No CLI mappings. |
Maps type and size properties to native parameters. 21 variants (3 types x 7 sizes). Display-only -- no state dimension.
| Figma | SwiftUI | Compose |
|---|---|---|
| dark-initials | type: .darkInitials | AvatarType.DarkInitials |
| initials-light | type: .lightInitials | AvatarType.LightInitials |
| image | imageURL: URL | AvatarType.Image(url) |
| Figma | SwiftUI | Compose |
|---|---|---|
| 20px | size: .xxSmall | AvatarSize.XXSmall |
| 24px | size: .xSmall | AvatarSize.XSmall |
| 32px | size: .small | AvatarSize.Small |
| 40px | size: .medium | AvatarSize.Medium |
| 48px | size: .large | AvatarSize.Large |
| 64px | size: .xLarge | AvatarSize.XLarge |
| 90px | size: .xxLarge | AvatarSize.XXLarge |
3 type × 7 size=21 variants. No interaction state axis (display-only component).
| Type | Sizes | Notes | Count |
|---|---|---|---|
| dark-initials | 20, 24, 32, 40, 48, 64, 90 px | Brand background, white initials | 7 |
| initials-light | 20, 24, 32, 40, 48, 64, 90 px | Light background, dark initials | 7 |
| image | 20, 24, 32, 40, 48, 64, 90 px | Photo fill (raster expected) | 7 |
View full Type × Size breakdown (21 rows)
| Type | Size | Raster? | Node ID |
|---|---|---|---|
| dark-initials | 20px | -- | 17143:4489 |
| dark-initials | 24px | -- | 17143:4497 |
| dark-initials | 32px | -- | 17143:4505 |
| dark-initials | 40px | -- | 17143:4513 |
| dark-initials | 48px | -- | 17143:4523 |
| dark-initials | 64px | -- | 17143:4531 |
| dark-initials | 90px | -- | 17143:4539 |
| initials-light | 20px | -- | 17143:4492 |
| initials-light | 24px | -- | 17143:4500 |
| initials-light | 32px | -- | 17143:4508 |
| initials-light | 40px | -- | 17143:4517 |
| initials-light | 48px | -- | 17143:4526 |
| initials-light | 64px | -- | 17143:4535 |
| initials-light | 90px | -- | 17143:4542 |
| image | 20px | expected | 17143:4495 |
| image | 24px | expected | 17143:4503 |
| image | 32px | expected | 17143:4511 |
| image | 40px | expected | 17143:4521 |
| image | 48px | expected | 17143:4529 |
| image | 64px | expected | 17143:4546 |
| image | 90px | expected | 17143:4548 |
Raster column: ✗=initials background exported as raster image instead of vector. "expected"=image type uses photos by design.
type=initials - light renamed to type=initials-light across all 7 variants. Now matches dark-initials hyphen style. Fixedmain/avatar/brand/intials corrected to main/avatar/brand/initials. Token now maps correctly to native implementations. Fixedradius/radius-round (99999) instead of hardcoded per-size values (45.213px, 24px, 16px, 12px, 10px). FixedA colored pill/tag used to highlight an item's status for quick recognition. 68 variants across State (Primary/Brand/Info/Success/Warning/Danger/Disabled) x Level (Heavy/Medium/Light) x Type (Default/Voucher/Transaction/Dashboard). Display-only component with no interaction states.
Contexts are illustrative. Final screens will reference actual GCash patterns.
Toggle State, Level, and Type to see the badge update in real time.
main/badge/{semantic}/{level}/). Minor issues: State property names don't match token names (Info vs information, Success vs positive). Danger/Heavy and Disabled/Heavy Transaction variants have hardcoded opacity: 0.90.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | State + Level + Type | Display-only. All 7 states x 3 levels fully defined per type. |
Badge is display-only. No interaction states (pressed, focused) exist. Disabled is a visual variant, not an interactive state -- it represents muted/inactive content styling.
- Hardcoded
opacity: 0.90removed from Danger/Heavy Transaction (21:111576) and Disabled/Heavy Transaction (3714:3863) inner containers. Both now at opacity 1, consistent with the other 66 variants. C3 Fixed - State property values renamed to match token semantic names across all 60 affected variants:
Info→Information,Success→Positive,Warning→Notice,Danger→Negative,Disabled→Muted. Figma State values now align 1:1 with token namespace — cleaner Code Connect mapping, no translation layer needed. C2 Fixed
- Code Connect mappings not registered. Structural work through v1.1 (state rename, opacity fix) is done — registration can proceed against the 68-variant schema. C7 · Code Connect Linkability
- Add a leading icon slot. Common pattern in fintech status badges (warning triangle, check circle, info icon). Today consumers can't pair an icon with a badge without detaching. Slot
- Consider Medium / Light levels for Primary and Brand. These states only support Heavy today — if lower-emphasis variants are needed (subtle pill, ghost badge), add them now rather than waiting for a future break. Property
- Numeric count variant. Notification badges (inbox unread count, cart count) overlap Badge's visual space but aren't formalized here. Either document that consumers use Counter instead, or add a
countvariant with overflow handling ("99+"). Property
4 types with different shapes and sizing: Default (pill), Voucher (bottom-right radius only), Transaction (rounded rect), Dashboard (compact rounded rect). Each type supports 7 states x 3 levels (Primary and Brand are Heavy only).
Pill-shaped badge with full border-radius (99px). Standard status indicator for general use.
Badge with bottom-right radius only (4px). Used on voucher cards and promotional items. Fixed 18px height.
Rounded rectangle badge (4px radius). Used in transaction lists and history screens. Compact padding.
Compact rounded rectangle badge (4px radius) with smaller typography (10px). Used in dashboard widgets and summary cards.
Display-only component. All colors bound to main/badge/{semantic}/{level}/ tokens. Primary and Brand states only support Heavy level.
| State | Level | Role | Token | Value |
|---|---|---|---|---|
| Primary | Heavy | bg | main/badge/primary/heavy/background | #005CE5 |
| Primary | Heavy | label | main/badge/primary/heavy/label | #FFFFFF |
| Brand | Heavy | bg | main/badge/brand/heavy/background | #1972F9 |
| Brand | Heavy | label | main/badge/brand/heavy/label | #FFFFFF |
| Info | Light | bg | main/badge/information/light/background | #E5F1FF |
| Info | Light | label | main/badge/information/light/label | #005CE5 |
| Info | Medium | bg | main/badge/information/medium/background | #D2E5FF |
| Info | Medium | label | main/badge/information/medium/label | #005CE5 |
| Info | Heavy | bg | main/badge/information/heavy/background | #2340A9 |
| Info | Heavy | label | main/badge/information/heavy/label | #FFFFFF |
| Success | Light | bg | main/badge/positive/light/background | #E7F8F0 |
| Success | Light | label | main/badge/positive/light/label | #048570 |
| Success | Medium | bg | main/badge/positive/medium/background | #CAF2E0 |
| Success | Medium | label | main/badge/positive/medium/label | #048570 |
| Success | Heavy | bg | main/badge/positive/heavy/background | #12AF80 |
| Success | Heavy | label | main/badge/positive/heavy/label | #FFFFFF |
| Warning | Light | bg | main/badge/notice/light/background | #FCF0CA |
| Warning | Light | label | main/badge/notice/light/label | #966F0B |
| Warning | Medium | bg | main/badge/notice/medium/background | #F7D96E |
| Warning | Medium | label | main/badge/notice/medium/label | #966F0B |
| Warning | Heavy | bg | main/badge/notice/heavy/background | #CA970C |
| Warning | Heavy | label | main/badge/notice/heavy/label | #FFFFFF |
| Danger | Light | bg | main/badge/negative/light/background | #F8E6E6 |
| Danger | Light | label | main/badge/negative/light/label | #B50707 |
| Danger | Medium | bg | main/badge/negative/medium/background | #F4C7C9 |
| Danger | Medium | label | main/badge/negative/medium/label | #8D0710 |
| Danger | Heavy | bg | main/badge/negative/heavy/background | #D61B2C |
| Danger | Heavy | label | main/badge/negative/heavy/label | #FFFFFF |
| Disabled | Light | bg | main/badge/muted/light/background | #C2C5CA |
| Disabled | Light | label | main/badge/muted/light/label | #FFFFFF |
| Disabled | Medium | bg | main/badge/muted/medium/background | #9A9FA7 |
| Disabled | Medium | label | main/badge/muted/medium/label | #FFFFFF |
| Disabled | Heavy | bg | main/badge/muted/heavy/background | #717883 |
| Disabled | Heavy | label | main/badge/muted/heavy/label | #FFFFFF |
| Property | Default | Voucher | Transaction | Dashboard |
|---|---|---|---|---|
| Height | auto | 18px (fixed) | auto | auto |
| Padding H | 8px | 8px | 4px | 4px |
| Padding V | 2px (top) / 4px (bottom) | 2px (top) / 4px (bottom) | 1px (top) / 3px (bottom) | 1px |
| Corner radius | 99px (pill) | 0/0/4px/0 (BR only) | 4px | 4px |
| Type | Text Style | Font | Size | Tracking | Line-height |
|---|---|---|---|---|---|
| Default / Voucher / Transaction | Primary/Label/Fine | HeyMeow Rnd Bold | 12px | 0.5px | 12px |
| Dashboard | Primary/Label/Tiny | HeyMeow Rnd Bold | 10px | 0.25px | 10px |
iOS -- Swift Package Manager
// In Xcode: File -> Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios" // Or in Package.swift: .package( url: "https://github.com/AY-Org/eb-ds-ios", from: "1.0.0" )
Android -- Gradle (Kotlin DSL)
// build.gradle.kts (app) dependencies { implementation("com.eastblue.ds:badge:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.badge.* // Compose
Package not yet published. These are the planned distribution paths. API shape is final -- native implementation is pending.
Every row maps a Figma component property to its native equivalent.
| Figma Property | SwiftUI | Compose | Notes |
|---|---|---|---|
State=Primary | state: .primary | BadgeState.Primary | Heavy level only |
State=Brand | state: .brand | BadgeState.Brand | Heavy level only |
State=Info | state: .info | BadgeState.Info | Token: information |
State=Success | state: .success | BadgeState.Success | Token: positive |
State=Warning | state: .warning | BadgeState.Warning | Token: notice |
State=Danger | state: .danger | BadgeState.Danger | Token: negative |
State=Disabled | state: .disabled | BadgeState.Disabled | Token: muted |
Level=Heavy | level: .heavy | BadgeLevel.Heavy | Solid fill, white label |
Level=Medium | level: .medium | BadgeLevel.Medium | Mid-tone fill, dark label |
Level=Light | level: .light | BadgeLevel.Light | Subtle fill, dark label |
Type=Default | type: .default | BadgeType.Default | Pill shape (99px radius) |
Type=Voucher | type: .voucher | BadgeType.Voucher | Bottom-right radius only |
Type=Transaction | type: .transaction | BadgeType.Transaction | Rounded rect (4px) |
Type=Dashboard | type: .dashboard | BadgeType.Dashboard | Compact, smaller font |
// Success badge -- heavy level EBBadge("Completed", state: .success, level: .heavy) // Info badge -- light level EBBadge("Pending", state: .info, level: .light)
// Success badge -- heavy level EBBadge( text = "Completed", state = BadgeState.Success, level = BadgeLevel.Heavy ) // Info badge -- light level EBBadge( text = "Pending", state = BadgeState.Info, level = BadgeLevel.Light )
EBBadge("50% OFF", state: .danger, level: .heavy, type: .voucher)
EBBadge( text = "50% OFF", state = BadgeState.Danger, level = BadgeLevel.Heavy, type = BadgeType.Voucher )
EBBadge("Failed", state: .danger, level: .heavy, type: .transaction)
EBBadge( text = "Failed", state = BadgeState.Danger, level = BadgeLevel.Heavy, type = BadgeType.Transaction )
EBBadge("Active", state: .success, level: .light, type: .dashboard)
EBBadge( text = "Active", state = BadgeState.Success, level = BadgeLevel.Light, type = BadgeType.Dashboard )
| Requirement | iOS | Android |
|---|---|---|
| Accessibility label | accessibilityLabel("Status: Completed") | contentDescription="Status: Completed" |
| Decorative mode | isAccessibilityElement=false (when status is conveyed elsewhere) | importantForAccessibility=no |
| Color contrast | Heavy levels meet WCAG AA (4.5:1+) | Heavy levels meet WCAG AA (4.5:1+) |
| Non-color indicator | Badge text conveys meaning alongside color | Badge text conveys meaning alongside color |
Do
Use semantic states that match the content meaning (Success for completed, Danger for failed, Warning for pending).
Don't
Use badges for interactive elements -- badges are display-only status indicators.
Do
Use Heavy level for primary status indicators and Light/Medium for secondary or supporting context.
Don't
Use multiple Heavy badges in the same row -- visual noise. Use one Heavy + rest Light/Medium.
Do
Match badge Type to context: Default for general, Voucher for promos, Transaction for history, Dashboard for summaries.
Don't
Mix badge Types within the same list or group.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Simple single-layer structure: container with text child. Semantic naming. |
| C2 | Variant & Property Naming | Ready | Clean enum properties: State, Level, Type. Minor: State names don't match token semantic names. |
| C3 | Token Coverage | Ready | All colors bound to main/badge/ tokens. Note: 2 variants have hardcoded opacity: 0.90. |
| C4 | Native Mappability | Ready | Maps to custom EBBadge on both platforms. Simple text + background shape. |
| C5 | Interaction State Coverage | N/A | Display-only component. No interactive states needed. |
| C6 | Asset & Icon Quality | N/A | No icons or assets. Text-only component. |
| C7 | Code Connect Linkability | Needs Refinement | No CLI mappings registered yet. Property naming is clean and ready for mapping. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Ready | Clean enum properties: State (7), Level (3), Type (4). Ready for Code Connect mapping. |
| Token coverage | Ready | All colors token-bound. Minor opacity inconsistency on 2 variants. |
| State coverage | N/A | Display-only. No interaction states. |
| Native component file | Pending | EBBadge.swift / EBBadge.kt not yet created |
7 State values x 3 Level values x 4 Type values=84 theoretical. Primary and Brand only support Heavy level, so actual count is (2 x 1 x 4) + (5 x 3 x 4)=8 + 60=68 variants.
| State | Level | Types | Notes |
|---|---|---|---|
| Primary | Heavy | Default, Voucher, Transaction, Dashboard | 4 variants |
| Brand | Heavy | Default, Voucher, Transaction, Dashboard | 4 variants |
| Info | Light / Medium / Heavy | Default, Voucher, Transaction, Dashboard | 12 variants |
| Success | Light / Medium / Heavy | Default, Voucher, Transaction, Dashboard | 12 variants |
| Warning | Light / Medium / Heavy | Default, Voucher, Transaction, Dashboard | 12 variants |
| Danger | Light / Medium / Heavy | Default, Voucher, Transaction, Dashboard | 12 variants |
| Disabled | Light / Medium / Heavy | Default, Voucher, Transaction, Dashboard | 12 variants |
opacity: 0.90 removed from Danger/Heavy Transaction (21:111576) and Disabled/Heavy Transaction (3714:3863) inner containers. Both now at opacity 1, consistent with the other 66 variants. FixedInfo ->Information, Success ->Positive, Warning ->Notice, Danger ->Negative, Disabled ->Muted. Figma State property now aligns 1:1 with token namespace (main/badge/{information|positive|notice|negative|muted}/{level}/). Cleaner Code Connect mapping with no translation layer. Fixedmain/badge/{semantic}/{level}/ tokens. Documentedopacity: 0.90 on container instead of using token-driven values. Inconsistent with other variants. Fixed in 1.1.0A neutral-background promotional banner — leading image or icon alongside a text stack (optional preamble, heading, optional description) and an optional button link. 20 Figma variants across Property (Within A Container / Full Width) × position (left / right) × with link × with button × with preamble × with icon. Shares ~95% of its schema with Carousel - Item — the only meaningful difference is the carousel's peek/snap behaviour, which belongs on the container, not the item.
with link, with button, with preamble, with icon) don't survive codegen. with link + with button describe mutually exclusive CTAs and should be one action enum. with icon is too narrow — a leading asset slot accepting Icon / Avatar / Illustration / Image is more reusable. Property=Within A Container | Full Width is a padding/layout concern owned by the parent. Background image and chevron should be vector slots. Finally, Banner and Carousel - Item share enough DNA to be one component with carousel behaviour on the container.Banner is used in-flow as a promotional callout — typically between sections on a Home or Dashboard screen. "Within A Container" leaves horizontal padding on either side so the banner sits as a card; "Full Width" bleeds edge-to-edge. The image or icon sits on the opposite side of the text per the position axis.
Edit the text or flip the property toggles to see how the banner reflows. The image is a flat placeholder in this preview; in Figma it's an instance of a separate "Banner Asset Placeholder" component.
main/banner/color/*. But the chevron on the button link is a raster shape_full PNG, and the icon placeholder is a drawn circle — neither is self-contained as a vector instance.with link, with button, with preamble, with icon) — no other DS component uses spaces in property names. Property=Within A Container | Full Width is a padding concern named like a semantic mode. with link + with button are mutually exclusive but modeled as independent booleans.HStack / Compose Row. Duplicates ~95% of Carousel - Item's schema — the two should be one component.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | Property × position × with link × with button × with preamble × with icon | Static banner; whole card is the tap target when an action is present. |
| Pressed | Missing | Missing | Not built | Tappable banner lacks pressed feedback — needs a subtle scale-down or overlay tint. |
| Focused | Missing | Missing | Not built | Keyboard / D-pad focus ring needed when used in an a11y-first flow. |
| Within A Container | Container padding | Container padding | Property=Within A Container | 12 px outer padding + 8 px corner radius around the banner card. Owned by the parent layout on native — not a component variant. |
| Full Width | Edge-to-edge | Edge-to-edge | Property=Full Width | No outer padding, no corner radius. Also owned by the parent layout. |
- Property names use spaces.
with link,with button,with preamble,with iconaren't valid identifiers in any native codegen target. Should be camelCase:hasLink,hasAction,hasPreamble,hasLeadingAsset. C2 · Variant & Property Naming Propertyis a meta-name, not a semantic one. Its values (Within A Container/Full Width) describe outer padding and corner radius — a layout concern the parent should own. Rename topaddingif kept, or drop the axis entirely and let the consumer control width + padding. C2 · Variant & Property Namingwith link+with buttonencode mutually exclusive CTAs as independent booleans. Both are text + chevron link styles — the distinction is cosmetic. This schema admits the impossible statewith link=yes+with button=yes(excluded by authoring convention, not by the schema). Collapse into oneactionenum. C2 · Variant & Property Naming- Sparse cartesian variant space. 5 boolean-ish axes × 2 container modes would yield 64 combinations; only 20 ship. Author manually pruned invalid combos — that logic should live in the schema (enums, mutually exclusive props), not in variant authoring discipline. C1 · Layer Structure & Naming
- No first-class image slot. The image is an instance of a separate "Banner Asset Placeholder" component — product teams swap via instance-override rather than a declared Figma Slot. Should be a named
assetslot accepting an Image, Illustration, or Gradient. C4 · Native Mappability with icon=yesrenders a drawn grey circle. The icon slot is a flat#C2C6CFcircle, not a swappable Icon / Avatar / Illustration instance. Can't carry a token-bound color or glyph. C6 · Asset & Icon Quality- Chevron is a raster
shape_fullPNG. The "learn more" arrow ships as an<img>asset. Doesn't scale cleanly and can't take a token tint. C6 · Asset & Icon Quality - No pressed / focused / disabled states. The whole banner is tappable but only Default is modeled. Native platforms need pressed (tap feedback) and focused (keyboard/D-pad) at minimum. C5 · Interaction State Coverage
- Container padding modeled as a component variant.
Within A Containeradds 12 px outer padding and wraps the inner card in a rounded container;Full Widthdrops both. On native this is a layout-level decision (parent gives Banner its width + padding), not a Figma variant. C4 · Native Mappability - Code Connect mappings not registered. Blocked until properties are renamed, axes are collapsed, and asset/background slots are adopted. C7 · Code Connect Linkability
- Rename space-separated booleans to camelCase.
with link→hasLink,with button→hasAction,with preamble→hasPreamble,with icon→hasLeadingAsset. Matches the DS-wide naming fix applied to Carousel Item and the Form family. Rename - Collapse
with link+with buttoninto oneactionenum.action: .none | .link("Label") | .button("Label"). Mutually exclusive CTAs shouldn't be modeled as independent booleans — the schema should makewith link=yes + with button=yesunrepresentable. Property - Replace
with iconboolean with a leadingassetslot. Accept an Icon, Avatar, Illustration, or Image instance. Native:leadingAsset: @ViewBuilder(SwiftUI) /leadingAsset: @Composable () -> Unit(Compose). Eliminates the rigid icon-only placeholder. Slot - Add a
background/ image slot. Replace the "Banner Asset Placeholder" instance with a first-class Figma Slot that accepts an Image, Illustration, or Gradient. Native:background: AnyView/background: @Composable () -> Unit. Slot - Rename
Property→ drop it or make itpadding.Within A ContainervsFull Widthis a padding + radius concern owned by the parent layout on native. Either remove the axis entirely (the banner fills whatever width its parent hands it) or rename topadding: .container | .full. Property - Rename
position→imagePosition. More specific and self-documenting. Keep as.left | .rightenum. Rename - Consolidate with Carousel - Item. Both components have preamble / heading / description / button / image-with-position slots. The only difference is peek/snap behaviour — which belongs on the carousel container (scroll snap + scale/opacity transforms), not a sibling component. Target: one
EBBannerused standalone or insideEBCarousel. Family - Vectorize the chevron. Swap the raster
shape_fullfor a vector Icon instance — crisp at any scale, token-bound tint. Asset - Add pressed + focused states. Pressed: 0.98 scale or 6–8% overlay on tap. Focused: 2 px focus ring at
border/focus. Banners are always tappable and need both feedback signals. State - Announce as a single actionable element. VoiceOver and TalkBack should read preamble + heading + description + action label as one announcement with a button/link role. A11y
756:82655The most content-rich variant — preamble + heading + description + button link, with the image on the right and the content column left-aligned. Wraps in a rounded card with 12 px outer padding.
360 × 155 (hug)12 (space/space-12)168 (radius/radius-3)216 (pl=120 reserved for image)2 between lines · 16 before button (space/space-2, space/space-16)360 × 152 absolute (centered, behind content)24 × 24| ROLE | TOKEN | DEFAULT |
|---|---|---|
| Card bg | main/banner/color/bg | #FFFFFF |
| Preamble | main/banner/color/label-preamble @ 60% | #072592 |
| Heading | main/banner/color/label-title | #072592 |
| Description | main/banner/color/description | #6780A9 |
| Action label | main/banner/color/label-link | #005CE5 |
| Chevron tint | main/banner/color/icon | #005CE5 |
Primary/Label/FineProxima Soft Bold · 12 / 12+0.5 (tracking-wider)Primary/Headlines/BlockProxima Soft Bold · 18 / 23+0.25 (tracking-wide)Secondary/Bold/CaptionBarkAda Semibold · 12 / 18Secondary/Heavy/BaseBarkAda Bold · 14 / 20asset slotshape_full today756:82667Edge-to-edge variant — no outer padding, no corner radius. The banner's own 16 px padding sits directly against the screen edges. Used when the banner is the hero element of the section.
360 × 119 (hug)0 (full-width)160184360 × 152 absolute (centered)Property=Full Width → padding=nonelet parent control width + padding756:82657Icon-led variant — replaces the image with a drawn grey circle placeholder. No action CTA. Used when the banner is informational rather than promotional.
360 × 176 (hug)19.692 × 19.692 circle (#C2C6CF)240 (pl=119)4 between icon and headingwith icon → leadingAsset slot.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBBanner
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
Property: Within A Container | Full Width | padding: container | none(or drop) | Parent layout | Parent layout |
position: left | right | imagePosition: left | right | imagePosition: .left | imagePosition=EBBannerImagePosition.Left |
with preamble | preamble?: String | preamble: String? | preamble: String? |
with link + with button | action: .none | .link | .button | action: EBBannerAction? | action: EBBannerAction? |
with icon | leadingAsset slot | leadingAsset: () -> AnyView | leadingAsset: @Composable (() -> Unit)? |
| (Banner Asset Placeholder instance) | background: Image | Illustration slot | background: AnyView | background: @Composable () -> Unit |
| (raster chevron) | vector Icon | Built into .link/.button action | Built into .link/.button action |
| (not modeled) | onTap: () -> Void | onTap: (() -> Void)? | onClick: (() -> Unit)? |
ios/Components/Banner/EBBanner.swiftios/Components/Banner/EBBannerAction.swift—.none | .link | .buttonandroid/components/banner/EBBanner.ktandroid/components/banner/EBBannerAction.kt
// Promo banner — image on the right, button link action EBBanner( preamble: "PROMO", heading: "New user bonus", description: "₱50 cashback on your first transfer.", action: .button("Claim"), imagePosition: .right, background: { Image("promo-hero").resizable() } ) { onClaim() } // Info banner — leading icon, no action EBBanner( heading: "Verify your account", description: "Tap here to complete your KYC.", action: .none, imagePosition: .left, leadingAsset: { Icon(.infoCircle) } )
// Promo banner — image on the right, button link action EBBanner( preamble = "PROMO", heading = "New user bonus", description = "₱50 cashback on your first transfer.", action = EBBannerAction.Button("Claim") { onClaim() }, imagePosition = EBBannerImagePosition.Right, background = { Image(painterResource(R.drawable.promo_hero), contentDescription = null) } ) // Info banner — leading icon, no action EBBanner( heading = "Verify your account", description = "Tap here to complete your KYC.", imagePosition = EBBannerImagePosition.Left, leadingAsset = { EBIcon(EBIcons.InfoCircle) } )
| Requirement | iOS | Android |
|---|---|---|
| Whole-card tap target | Wrap in Button with combined accessibilityLabel (preamble + heading + description + action). | Modifier.clickable().semantics(mergeDescendants=true). |
| Role announcement | .accessibilityAddTraits(.isButton) when action is set. | Role.Button inside semantics. |
| Decorative image | .accessibilityHidden(true) on the background image view. | contentDescription=null on the background Image. |
| Focus ring | Default SwiftUI focus ring (tvOS + iPadOS keyboard nav). | D-pad focus: 2 dp outline at border/focus. |
| Min touch target | 360 × (93-176) ≫ 44 pt ✓ | 360 × (93-176) ≫ 48 dp ✓ |
- Use Banner for in-flow promo / info callouts — between content sections.
- Pick
imagePositionbased on reading flow — LTR locales usually prefer.leftimage + right-aligned text, or vice versa. - Keep the heading to 1-2 lines — 18 pt Bold headline, narrow content column.
- Use preamble for category context (PROMO, NEW, TIP).
- Let the parent layout decide outer padding — don't rely on
Property=Within A Container.
- Don't use both a link and a button —
actionis single-CTA by design. - Don't stack Banner inside a carousel — use Carousel - Item (or, post-consolidation,
EBBannerinsideEBCarousel). - Don't bake text into the image — background is decorative only.
- Don't use the drawn-circle icon placeholder in production — always pass a real
leadingAsset.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Requires Rework | Sparse cartesian — 5 boolean-ish axes × 2 container modes yields 64 combos; 20 ship. Container-padding axis conflates layout with identity. |
| C2 | Variant & Property Naming | Requires Rework | Property names use spaces (with link, with button, with preamble, with icon). Property is a meta-name. with link + with button encode mutually exclusive CTAs as two booleans. |
| C3 | Token Coverage | Ready | All colors bind to main/banner/color/*; radii to radius/radius-3; spacing to space/space-*; bg to main/banner/color/bg. |
| C4 | Native Mappability | Requires Rework | Image is an instance (not a slot); container-padding axis doesn't map to native; icon-only asset axis is rigid. |
| C5 | Interaction State Coverage | Requires Rework | Only Default — no pressed, focused, or disabled for a tappable banner. |
| C6 | Asset & Icon Quality | Requires Rework | Raster shape_full chevron. Drawn grey circle icon placeholder. Image asset is a separate component instance. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on property renames, axis collapse, and slot adoption. |
Property (2) × position (2) × with link × with button × with preamble × with icon — if all boolean combinations shipped, 2 × 2 × 2 × 2 × 2 × 2=64 variants. The author pruned invalid combos (with link=yes + with button=yes, with icon=yes + with button=yes, etc.) and shipped 20 variants — 10 per Property mode, mirrored across position=left|right.
| Content shape | Property | Count | Example nodes (left · right) |
|---|---|---|---|
| heading + desc + button | Within A Container | 2 | 756:82659 · 756:82653 |
| heading + desc + button | Full Width | 2 | 756:82669 · 756:82667 |
| heading + desc + icon (no action) | Within A Container | 2 | 756:82657 · 756:82658 |
| heading + desc + icon (no action) | Full Width | 2 | 756:82668 · 756:82672 |
| preamble + heading + desc + button | Within A Container | 2 | 756:82655 · 756:82656 |
| preamble + heading + desc + button | Full Width | 2 | 756:82664 · 756:82662 |
| heading + desc + link | Within A Container | 2 | 756:82654 · 756:82671 |
| heading + desc + link | Full Width | 2 | 756:82663 · 756:82670 |
| heading + desc (no action) | Within A Container | 2 | 756:82665 · 756:82666 |
| heading + desc (no action) | Full Width | 2 | 756:82661 · 756:82660 |
View full variant breakdown (20 rows)
| Node | Property | position | with link | with button | with preamble | with icon | Dimensions |
|---|---|---|---|---|---|---|---|
756:82653 | Within A Container | right | no | yes | no | no | 360 × 143 |
756:82659 | Within A Container | left | no | yes | no | no | 360 × 143 |
756:82667 | Full Width | right | no | yes | no | no | 360 × 119 |
756:82669 | Full Width | left | no | yes | no | no | 360 × 119 |
756:82658 | Within A Container | right | no | no | no | yes | 360 × 176 |
756:82657 | Within A Container | left | no | no | no | yes | 360 × 176 |
756:82672 | Full Width | right | no | no | no | yes | 360 × 152 |
756:82668 | Full Width | left | no | no | no | yes | 360 × 152 |
756:82656 | Within A Container | right | no | yes | yes | no | 360 × 155 |
756:82655 | Within A Container | left | no | yes | yes | no | 360 × 155 |
756:82662 | Full Width | right | no | yes | yes | no | 360 × 131 |
756:82664 | Full Width | left | no | yes | yes | no | 360 × 131 |
756:82671 | Within A Container | right | yes | no | no | no | 360 × 141 |
756:82654 | Within A Container | left | yes | no | no | no | 360 × 141 |
756:82670 | Full Width | right | yes | no | no | no | 360 × 117 |
756:82663 | Full Width | left | yes | no | no | no | 360 × 117 |
756:82666 | Within A Container | right | no | no | no | no | 360 × 117 |
756:82665 | Within A Container | left | no | no | no | no | 360 × 117 |
756:82660 | Full Width | right | no | no | no | no | 360 × 93 |
756:82661 | Full Width | left | no | no | no | no | 360 × 93 |
with link, with button, with preamble, with icon, and meta-named Property. Mutually exclusive CTAs modeled as independent booleans. OpenProperty=Within A Container | Full Width conflates layout with identity. OpenThe bottom-anchored sheet surface used to host list pickers, confirmations, forms, filters, and onboarding pages. Currently registered in Figma as Bottom Drawer (2 variants via Alignment=Left Align | Center Align). The actual in-product usage spans dozens of content shapes — ID pickers, transfer confirmations, tips lists, welcome cards, switch-account confirmations — but the DS component only models the top header block plus two hard-baked CTAs; content is 4 decorative placeholder rectangles. This is really a sheet header masquerading as a full sheet.
Modal (18507:71705) and Overlay (47:329691), all three independently declaring what a floating-surface-over-scrim looks like. Proposal: rebuild as EBBottomSheet — a thin wrapper around SwiftUI .sheet / Compose ModalBottomSheet — with explicit dragHandle, header, content, and footer slots, and consume the already-assessed Overlay for the scrim.Bottom Sheet anchors to the bottom edge over a dimmed background. In the sticker-sheet context file (12522:109042), instances are used across a wide range of content shapes: ID pickers, confirmation dialogs, transfer summaries, tips lists, welcome cards, and switch-account prompts — each with different inner composition, all wrapped in the same surface.
Toggle between Alignment values and the proposed future controls (detent, drag handle, scrim). The content options below the current Figma axis are previews of the proposed restructure — none exist in Figma today.
main/bottom-header/color/*, but the component is named Bottom Drawer — neither name matches common usage "Bottom Sheet". The Alignment axis silently changes the shape of the component (Center drops Close X, adds a headerSlot). Two axis values behave like two separate components.| Behavior | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Present / dismiss | Yes | Yes | Not annotated | iOS: .sheet(isPresented:). Android: ModalBottomSheet(onDismissRequest:). Slide-up entrance implied by pattern, not documented on the component. |
| Drag handle (grabber) | Yes | Yes | Missing | No handle node in Figma. iOS renders via .presentationDragIndicator(.visible). Material 3 renders via ModalBottomSheet(dragHandle={ BottomSheetDefaults.DragHandle() }). |
| Detent snapping (medium / large) | Yes | Yes | Missing | Sheet height in Figma is driven by content height only — no half / full axis. Natively handled via .presentationDetents([.medium, .large]) / SheetValue.PartiallyExpanded. |
| Swipe-down-to-dismiss | Yes | Yes | Not annotated | Platform-native gesture — should be configurable via a dismissible boolean on the wrapper. |
| Scrim / tap-outside dismiss | Yes | Yes | Not composed | Scrim lives in the separate Overlay component today (47:329691). Sheet should consume it, not redraw. |
| Close button (X) | Yes | Yes | Asymmetric | Only present on Left Align. Raster PNG. Should become a trailing slot in a title-bar region, available to both alignments. |
| Header slot (e.g. stepper) | Yes | Yes | Center Align only | Center Align silently adds a headerSlot used for progress bars / steppers. Left Align has no equivalent. Either surface it on both or model as its own region. |
| Content scroll lock | Implicit | Implicit | Not documented | Background scroll locked while sheet is presented; sheet's own content scrolls independently when detent < content height. |
| Empty / loading / error (content) | Yes | Yes | Not modeled | Content slot owner's responsibility; sheet itself has no intrinsic empty/loading/error state. |
- Component scope is the header, not the sheet. "Bottom Drawer" only models the rounded top surface plus a header and hard-baked CTA row. The actual sheet primitives — drag handle, detents, scrim, snap behaviour, swipe-down-to-dismiss — are absent. Every product usage in the context file (
12522:109042) has to re-compose the sheet by hand. C1 · Layer Structure & Naming - Content region is 4 decorative placeholder rectangles, not a Slot. Inside the body,
UI Slot,SLOT 2,SLOT 3,SLOT 4are pink-dashed#FFECF8rectangles toggled by booleansshowSlot1..4. They are not Figma Slots — designers can't instance-swap in Action List rows, form fields, or Filter chips without detaching. C1 · Layer Structure & Naming - Alignment axis hides a second component. Left Align and Center Align are not just text-alignment differences: Left Align has an icon placeholder + title block + Close X on the right; Center Align has a separate
headerSlot(used for progress bars / steppers) + title block and no Close X. These are two distinct layouts collapsed into one enum. C2 · Variant & Property Naming - No drag handle primitive. Nothing in the Figma tree renders a drag handle ("grabber"). iOS and Android expect this as an explicit visual affordance the user grabs to resize. Either add a handle node bound to a token, or document that rendering is delegated to the platform primitive. C5 · Interaction State Coverage
- No detent axis. There's no
medium / large / fitContentaxis on the component. Sheet height is whatever the decorative slots sum to. Native APIs require a discrete detent set; the Figma model doesn't reflect this. C4 · Native Mappability - Scope overlap with Modal and Overlay. Bottom Sheet, Modal (
18507:71705), and Overlay (47:329691) all independently model "surface above a scrim". None of them compose each other. The scrim should live in Overlay (already assessed); Modal and Bottom Sheet should consume it. C7 · Code Connect Linkability - Close X is raster and asymmetric. The close icon is a Figma CDN PNG asset (
shape_full) rendered only on Left Align — Center Align has no dismiss affordance at all. Both alignments should offer the same affordance, and it should be a vector Icon instance bound tomain/bottom-header/color/icon-close. C6 · Asset & Icon Quality - CTAs are hard-baked, not composable. A single primary button + a single tertiary button are instance-swapped inside the component with booleans
primaryAction/secondaryAction. Consumers who need one button, two horizontal buttons, three stacked options, or a link-only footer have to detach. CTA should be a footer slot receiving any button composition. C4 · Native Mappability - Icon placeholder is a raw grey circle, not a Slot. Left Align's leading icon is a hardcoded
#C2C6CFcircle inside anicon-placeholderframe. Same anti-pattern as Modal's icon slot. Should be a Figma Slot backed by the Icon component. C1 · Layer Structure & Naming - Token namespace and component name disagree. The component is named "Bottom Drawer" while its tokens live in
main/bottom-header/color/*. DS literature (Material, HIG) and this report use "Bottom Sheet". Pick one name and propagate: rename the component, rename the token collection, or both. C2 · Variant & Property Naming - No Code Connect mapping. Blocked until the restructure lands — mapping the current schema would hardcode the wrong architecture. C7 · Code Connect Linkability
- Restructure around a clean shell with four named slots. Target shape:
EBBottomSheet(isPresented, detents, dragHandle, header, content, footer). header=optional title bar region (title, preamble, leading icon, trailing icon / close). content=the one and only body Slot — accepts any DS composition (Action List, form fields, filter chips, tips list). footer=button group pinned to the bottom. Every current product usage becomes a composition: list picker=BottomSheet + Action List; confirmation=BottomSheet + description + button group; form=BottomSheet + Labeled Fields + button group; tips list=BottomSheet + numbered list; welcome card=BottomSheet + illustration + button group. Property - Promote the body to a real Figma Slot. Replace the 4 pink-dashed placeholder rectangles with a single named
contentSlot (Figma's Slot feature). Default to an empty 24-padded frame; let consumers instance-swap in Action List rows, form fields, or any other DS primitive without detaching. Slot - Consolidate the Bottom Sheet / Modal / Overlay family. Canonical hierarchy:
Overlay(scrim primitive, already shipped) → consumed by bothModal(centered dialog) andBottom Sheet(bottom-anchored sheet). The three ship distinct anchor positions but share the scrim. Do not collapse Modal and Bottom Sheet into one — native platforms treat them as separate APIs (.sheetvs.alert/DialogvsModalBottomSheet). Family - Replace the Alignment enum with a proper header schema. Split the silent shape-shift into explicit properties:
titleAlignment=left | center(just text-align),leadingSlot(icon / avatar / empty),trailingSlot(close X / icon button / empty),aboveTitleSlot(stepper / progress bar / empty). Both alignments now share the same structural shape, just different text-align and slot content. Property - Add an explicit detent axis. Introduce
Detent=medium | large | fitContentas a Figma variant — even if visually similar, this makes the Code Connect mapping 1:1 with.presentationDetents([.medium, .large])/SheetValue.PartiallyExpanded. Designers can then show in mocks which detent a sheet resolves to. Property - Add a drag-handle primitive. Vector rect, 32×4, radius 4, bound to a new token
main/bottom-header/color/drag-handle(suggest#C2C6CF). Ship as its own tiny component so Modal-style sheets can omit it and Bottom Sheet can include it. Default visible for Bottom Sheet. Asset - Footer action group should be a slot, not baked buttons. Replace the
primaryAction+secondaryActionbooleans with afooterslot that accepts any button composition — 0, 1, 2 horizontal, 2 vertical, link-only, icon+label. Same fix that Modal needs, and both should share a newEBButtonGroupprimitive if the team wants to keep the DS tight. Slot - Replace the raster close icon with a vector instance. Swap the Figma CDN PNG close for the DS vector Close icon, and bind colour to
main/bottom-header/color/icon-close(#6780A9). Available on both alignments via the trailing slot. Asset - Convert the leading icon-placeholder into an Icon slot. Same pattern as Modal: add a Figma Slot for the leading icon backed by the Icon component so designers can swap without detaching. Default to a neutral status icon or nothing. Slot
- Rename the component and its token namespace. Pick one: either rename the component to Bottom Sheet and rename the token collection from
main/bottom-header/color/*tomain/bottom-sheet/color/*, or keep "Drawer" and align tokens tomain/bottom-drawer/*. Current disagreement between component name, token name, and common DS vocabulary costs designers every time they search. Recommended: rename to Bottom Sheet to match Material / HIG / this assessment. Rename - Annotate the present / dismiss / gesture contract. Document on the component: slide-up entrance, fade-out-with-scrim exit, swipe-down-to-dismiss, tap-outside-dismiss, ESC/back button behaviour, focus trap, restore-focus-on-close. Developers currently have to infer these from adjacent patterns. Docs
- Add a dismissible/modal switch. Some flows (transfer confirmation, destructive action) need a non-swipe-dismiss sheet. Surface this as
dismissible: boolon the component, mapping to iOS.interactiveDismissDisabled(!dismissible)and ComposesheetState.confirmValueChange. State
12522:12860Header aligned to the leading edge. Ships with an optional leading icon placeholder, preamble + title stack, a trailing close X, then the decorative body slots and 2 hard-baked CTAs.
Bottom DrawerLeft Alignboolean (default true)boolean (default true)string "Title here of the header..."boolean (default true)booleans — decorative onlyboolean (default true)boolean (default true)| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface bg | main/bottom-header/color/bg | #FFFFFF |
| Preamble | main/bottom-header/color/preamble | #90A8D0 |
| Title / Header | main/bottom-header/color/header | #0A2757 |
| Description | main/bottom-header/color/description | #445C85 |
| Close icon | main/bottom-header/color/icon-close | #6780A9 |
| Icon placeholder | (hardcoded) | #C2C6CF |
| Primary CTA bg | main/button/primary/brand/enabled/bg | #005CE5 |
| Primary CTA label | main/button/primary/brand/enabled/label | #FFFFFF |
| Tertiary CTA label | main/button/tertiary/brand/enabled/label | #005CE5 |
main/bottom-header/color/*. Icon-placeholder grey is hardcoded — should be replaced with an Icon Slot bound to a semantic token.36032480pt 24 · pb 8 · pl 24 · pr 488624 sides · 32 bottom12px 24 · pb 361299 (pill)24 · top 24 · right 24Primary/Label/SmallProxima Soft · Bold · 14 / 140.25Primary/Headlines/SectionProxima Soft · Bold · 22 / 260Secondary/Default/BaseBarkAda · Medium · 14 / 20Primary/Label/LargeProxima Soft · Bold · 18 / 18left// Left-aligned header · two CTAs stacked .sheet(isPresented: $showSheet) { EBBottomSheet( titleAlignment: .leading, leading: { EBAvatar(image) }, trailing: { EBIconButton(.close) { showSheet = false } }, preamble: "Preamble here", title: "Title here of the header", description: "This area is designated for descriptions." ) { // content slot — any DS composition EBActionList { ... } } footer: { EBButton("Label") { ... } EBTextButton("Label") { ... } } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) }
// Left-aligned header · two CTAs stacked if (showSheet) { EBBottomSheet( titleAlignment = Alignment.Start, leading = { EBAvatar(image) }, trailing = { EBIconButton(Icons.Close) { showSheet = false } }, preamble = "Preamble here", title = "Title here of the header", description = "This area is designated for descriptions.", detents = listOf(Detent.Medium, Detent.Large), onDismissRequest = { showSheet = false }, footer = { EBButton(onClick = {}) { Text("Label") } EBTextButton(onClick = {}) { Text("Label") } } ) { EBActionList { ... } } }
12817:43834Header centered. Silently drops the leading icon + trailing close X and adds an above-title headerSlot used for progress bars / steppers. Same body placeholders and hard-baked CTAs as Left Align.
Bottom DrawerCenter Alignboolean (default true) — above-titleboolean (default true)string "Title here of the header..."boolean (default true)boolean (default true)boolean (default true)| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface bg | main/bottom-header/color/bg | #FFFFFF |
| Preamble | main/bottom-header/color/preamble | #90A8D0 |
| Title / Header | main/bottom-header/color/header | #0A2757 |
| Description | main/bottom-header/color/description | #445C85 |
| Primary CTA bg | main/button/primary/brand/enabled/bg | #005CE5 |
| Primary CTA label | main/button/primary/brand/enabled/label | #FFFFFF |
| Tertiary CTA label | main/button/tertiary/brand/enabled/label | #005CE5 |
main/bottom-header/color/* bindings as Left Align. No icon-close token surfaced because the close X is absent here — this is the asymmetry called out in Open Issues.3603308pt 24 · pb 8 · px 24166~16 (progress bar / stepper)24 sides · 32 bottom12px 24 · pb 361299 (pill)Primary/Label/SmallProxima Soft · Bold · 14 / 14Primary/Headlines/SectionProxima Soft · Bold · 22 / 26Secondary/Default/BaseBarkAda · Medium · 14 / 20Primary/Label/LargeProxima Soft · Bold · 18 / 18center// Centered header · above-title progress bar · two CTAs .sheet(isPresented: $showSheet) { EBBottomSheet( titleAlignment: .center, aboveTitle: { EBProgressBar(step: 2, of: 5) }, preamble: "Preamble here", title: "Title here of the header", description: "This area is designated for descriptions." ) { // content slot EBForm { ... } } footer: { EBButton("Next") { ... } EBTextButton("Cancel") { ... } } .presentationDetents([.large]) .presentationDragIndicator(.visible) }
// Centered header · above-title progress bar · two CTAs if (showSheet) { EBBottomSheet( titleAlignment = Alignment.Center, aboveTitle = { EBProgressBar(step = 2, total = 5) }, preamble = "Preamble here", title = "Title here of the header", description = "This area is designated for descriptions.", detents = listOf(Detent.Large), onDismissRequest = { showSheet = false }, footer = { EBButton(onClick = {}) { Text("Next") } EBTextButton(onClick = {}) { Text("Cancel") } } ) { EBForm { ... } } }
// Swift Package Manager — requires iOS 16+ for .presentationDetents .package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
// build.gradle.kts — uses Material 3 ModalBottomSheet implementation("com.gcash.designsystem:eb-components:1.0.0") implementation("androidx.compose.material3:material3:1.2.0") import com.gcash.designsystem.components.EBBottomSheet
The current Figma schema (alignment + 9 booleans) is not fit for 1:1 mapping. The table below maps the proposed post-restructure schema to native APIs.
| Proposed Figma property | SwiftUI | Compose |
|---|---|---|
isPresented | .sheet(isPresented: $binding) | if (showSheet) ModalBottomSheet(onDismissRequest:) |
detents | .presentationDetents([.medium, .large]) | sheetState=rememberModalBottomSheetState(…) + Detent enum |
dragHandle=visible|hidden | .presentationDragIndicator(.visible / .hidden) | dragHandle={ BottomSheetDefaults.DragHandle() } or null |
titleAlignment=leading|center | titleAlignment: .leading / .center | titleAlignment=Alignment.Start / Center |
leading slot | leading: { EBAvatar(…) } (ViewBuilder) | leading: @Composable () -> Unit |
trailing slot (e.g. close) | trailing: { EBIconButton(.close) { dismiss() } } | trailing: @Composable () -> Unit |
aboveTitle slot (progress / stepper) | aboveTitle: { EBProgressBar(…) } | aboveTitle: @Composable () -> Unit |
preamble | preamble: String? | preamble: String?=null |
title | title: String | title: String |
description | description: String? | description: String?=null |
content slot | @ViewBuilder content: () -> Content (trailing closure) | content: @Composable ColumnScope.() -> Unit |
footer slot | footer: () -> Footer | footer: @Composable RowScope.() -> Unit |
dismissible | .interactiveDismissDisabled(!dismissible) | sheetState.confirmValueChange={ dismissible } |
(legacy) alignment=Left Align | → split into titleAlignment + leading + trailing | → split into titleAlignment + leading + trailing |
(legacy) showSlot1..4 booleans | → removed, replaced by content slot | → removed, replaced by content slot |
(legacy) primaryAction / secondaryAction | → removed, replaced by footer slot | → removed, replaced by footer slot |
ios/Components/BottomSheet/EBBottomSheet.swiftios/Components/BottomSheet/EBBottomSheetModifier.swift(thin wrapper around.sheet)ios/Components/BottomSheet/EBBottomSheetLegacy.swift(iOS 15 fallback viaUIViewControllerRepresentable)android/components/bottomsheet/EBBottomSheet.ktandroid/components/bottomsheet/EBBottomSheetDefaults.kt
// 1 · List picker — BottomSheet + Action List rows in content slot .sheet(isPresented: $showPicker) { EBBottomSheet( title: "Provide a valid ID to unlock GCredit", trailing: { EBIconButton(.close) { showPicker = false } } ) { EBActionList { ForEach(ids) { id in EBActionRow(id.name) { select(id) } } } } footer: { EBButton("Next") { advance() } EBTextButton("Do it later") { dismiss() } } .presentationDetents([.large]) } // 2 · Confirmation dialog — description + button group .sheet(isPresented: $showConfirm) { EBBottomSheet( title: "Are you sure?", description: "By proceeding, I agree to share my mobile number with GCash.", leading: { EBAvatar(recipient.photo) } ) { EmptyView() } footer: { EBButton("Proceed") { proceed() } } .presentationDetents([.medium]) } // 3 · Form — BottomSheet + Labeled Fields in content slot .sheet(isPresented: $showForm) { EBBottomSheet( titleAlignment: .center, aboveTitle: { EBProgressBar(step: 2, of: 5) }, title: "Welcome to GInsure!" ) { EBLabeledField("Full name", text: $name) EBLabeledField("Mobile number", text: $mobile) } footer: { EBButton("Next") { save() } EBTextButton("Cancel") { dismiss() } } .presentationDetents([.large]) } // 4 · Non-dismissible — destructive confirmation .sheet(isPresented: $showDelete) { EBBottomSheet( title: "You're about to switch accounts", dismissible: false ) { EmptyView() } footer: { EBButton("Proceed", role: .destructive) { proceed() } EBTextButton("Cancel") { showDelete = false } } .presentationDetents([.medium]) .interactiveDismissDisabled(true) }
// 1 · List picker — BottomSheet + Action List rows in content slot if (showPicker) { EBBottomSheet( title = "Provide a valid ID to unlock GCredit", trailing = { EBIconButton(Icons.Close) { showPicker = false } }, detents = listOf(Detent.Large), onDismissRequest = { showPicker = false }, footer = { EBButton(onClick = ::advance) { Text("Next") } EBTextButton(onClick = ::dismiss) { Text("Do it later") } } ) { EBActionList { ids.forEach { id -> EBActionRow(id.name, onClick = { select(id) }) } } } } // 2 · Confirmation dialog — description + button group EBBottomSheet( title = "Are you sure?", description = "By proceeding, I agree to share my mobile number with GCash.", leading = { EBAvatar(recipient.photo) }, detents = listOf(Detent.Medium), onDismissRequest = {}, footer = { EBButton(onClick = ::proceed) { Text("Proceed") } } ) { // no content } // 3 · Non-dismissible — destructive confirmation EBBottomSheet( title = "You're about to switch accounts", dismissible = false, detents = listOf(Detent.Medium), onDismissRequest = {}, footer = { EBButton(onClick = ::proceed, colors = EBButtonDefaults.destructiveColors()) { Text("Proceed") } EBTextButton(onClick = { showDelete = false }) { Text("Cancel") } } ) { }
| Requirement | iOS | Android |
|---|---|---|
| Modal trait | .sheet applies the modal trait automatically — VoiceOver traps focus inside the sheet. | ModalBottomSheet treats content as modal by default — TalkBack swipe is contained. |
| Focus management | Focus moves to the sheet on present; restores to trigger on dismiss. If a first-field focus is desired, use .focused($firstField). | Focus enters sheet content on show; restored to trigger on dismiss. Request initial focus via LaunchedEffect + focusRequester. |
| Title as heading | Mark the title Text with .accessibilityAddTraits(.isHeader) so VoiceOver reads it first. | Use Modifier.semantics { heading() } on the title; set paneTitle on the sheet surface. |
| Drag handle announcement | iOS's built-in grabber is announced as "Adjustable". Custom handles need .accessibilityLabel("Resize sheet") and .accessibilityAdjustableAction. | Material 3 default handle exposes resize action. Custom handles need Modifier.semantics { contentDescription="Resize sheet" }. |
| Dismiss gesture | Swipe-down + ESC + tap-outside all route through isPresented. For non-dismissible, use .interactiveDismissDisabled(true). | Back gesture + tap-outside via onDismissRequest. Non-dismissible: sheetState.confirmValueChange={ false }. |
| Close button (if trailing slot) | Wrap 24×24 icon in a ≥44×44pt tappable area. Label: .accessibilityLabel("Close"). | Wrap 24×24 icon in a ≥48×48dp tappable area. contentDescription=stringResource(R.string.close). |
| Destructive CTA | Use role: .destructive on the footer button. | Use EBButtonDefaults.destructiveColors() and explicit contentDescription. |
| Reduce motion | Respect UIAccessibility.isReduceMotionEnabled — skip slide-up / use cross-fade. | Respect Settings.Global.ANIMATOR_DURATION_SCALE — shorten animation when accessibility demands it. |
- Use Bottom Sheet for content picks, list selections, filter panels, confirmations with supporting context, and multi-step forms that benefit from an anchored surface.
- Always pair the sheet with Overlay for the scrim — delegate to the platform if possible (SwiftUI's
.sheetprovides it; Compose'sModalBottomSheetprovides it). - Show the drag handle when the sheet supports multiple detents. Hide it when the sheet is a single, fixed-height surface.
- Put heavy primary action in the footer slot; keep the content slot scrollable for long content.
- Use
titleAlignment: .centerwith theaboveTitleslot (progress bar / stepper) for onboarding and multi-step flows. - Make the sheet
dismissible: falseonly when an explicit user choice is required (destructive actions, legal confirmations).
- Don't use Bottom Sheet for quick toasts or brief notifications — use Alert or a snackbar pattern.
- Don't hardcode content into the sheet variant — use the
contentslot and compose DS primitives. - Don't use Bottom Sheet for full-screen navigation; use a route / screen instead.
- Don't stack bottom sheets — resolve the current sheet before presenting another.
- Don't draw your own scrim inside the sheet content; the platform or Overlay primitive owns it.
- Don't fork a new "Bottom Sheet" variant when a content shape changes — the shell stays the same, content in the slot varies.
- Don't omit the close affordance when
dismissible: false. Every sheet needs at least one way out (footer CTA or trailing close).
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Requires Rework | Scope is the header, not the sheet. Content region is 4 decorative placeholder rectangles instead of a Figma Slot. Icon-placeholder is a raw circle. Component name ("Bottom Drawer") disagrees with token namespace ("bottom-header") and DS convention ("Bottom Sheet"). |
| C2 | Variant & Property Naming | Requires Rework | Alignment axis collapses two structurally different layouts (Left has leading icon + close X; Center has above-title headerSlot + no close X). No detent axis. 9 booleans (showSlot1..4, primaryAction, secondaryAction, preamble, description, iconPlaceholder) that should be slots. |
| C3 | Token Coverage | Needs Refinement | Surface, preamble, header, description, close icon all bound to main/bottom-header/color/*. Icon-placeholder grey (#C2C6CF) is hardcoded. Drag-handle token doesn't exist yet (component has no handle). |
| C4 | Native Mappability | Requires Rework | Does not map to .sheet / ModalBottomSheet as-is. No detent axis, no drag handle, no dismissible contract, hard-baked CTAs. After restructure → clean 1:1 mapping. |
| C5 | Interaction State Coverage | Requires Rework | Only default state. No drag states (resting / dragging / snapping), no present / dismiss transition annotation, no empty / loading / error state guidance for the content slot. |
| C6 | Asset & Icon Quality | Needs Refinement | Close X is a remote raster PNG (shape_full from Figma CDN) rather than a vector Icon instance. Icon-placeholder is a raw #C2C6CF circle, not a vector icon slot. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on restructure. Scope overlap with Modal and Overlay must be resolved; mapping the current schema would hardcode the wrong architecture. |
| Aspect | Status | Notes |
|---|---|---|
| Component boundary | Requires Rework | Needs to consume Overlay and stop redeclaring the surface. Needs to expose a real content slot before mapping. |
| Property names | Requires Rework | Replace alignment + 9 booleans with titleAlignment + named slots (leading, trailing, aboveTitle, content, footer) + detents. |
| Token bindings | Needs Refinement | Most colours bound to main/bottom-header/color/*. Hardcoded icon-placeholder grey needs a token. Drag-handle token (new) needed. |
| Slot architecture | Requires Rework | No Figma Slots in use today. Promote body, footer, leading, trailing, and above-title to first-class Slots. |
| State coverage | Requires Rework | Add detent axis and dismissible boolean. Drag states are platform-native — document delegation. |
| Platform API alignment | Requires Rework | Target is iOS 16+ .sheet + Material 3 ModalBottomSheet. Fallback for iOS 15 via UIViewControllerRepresentable/UISheetPresentationController. |
Single axis — Alignment=Left Align | Center Align. The header shape-shifts across these two values (see Open Issues).
| # | Alignment | Node | Dimensions | Header slots present | Notes |
|---|---|---|---|---|---|
| 1 | Left Align | 12522:12860 | 360 × 324 | iconPlaceholder (leading) · preamble · title · Close X (trailing, raster) | Title + preamble left-aligned next to optional leading icon. Close X fixed top-right at (24, 24). |
| 2 | Center Align | 12817:43834 | 360 × 330 | headerSlot (above-title, e.g. progress bar) · preamble · title | No leading icon, no close X. Adds an headerSlot used for progress bars / steppers. Title + preamble centered. |
Gaps: No drag handle, no detent axis, no trailing close on Center Align, no leading icon on Center Align, no above-title slot on Left Align. The "two variants" hide at least four separate shape differences.
alignment). Reusable and Composable both Fail: content is decorative placeholders, CTAs are hard-baked, no Slot architecture. DocumentedUI Slot, SLOT 2..4) toggled by booleans instead of a Figma Slot. Openmain/bottom-header/color/*, DS convention is "Bottom Sheet". Recommend rename to Bottom Sheet. Open.sheet / ModalBottomSheet until restructure. Openshape_full). Should be a vector Icon instance bound to main/bottom-header/color/icon-close. Open18507:71705) and Overlay (47:329691) must be resolved first. OpenBarkAda (secondary font) at Secondary/Default/Base. Covered by the standing custom-font action item. InfoUsed to trigger an action when tapped. The button's Call to Action describes the action that will occur. The Large/Medium Buttons are the default size for the GCash app.
How the button appears in a real product screen — primary and secondary actions in a bottom sheet.

Toggle properties and appearance modes to see the button update in real time.
Leading Container and Trailing Container SLOT nodes in every variant.Property=Value naming across all 60 variants. Size, State, and Style are orthogonal variant dimensions. Appearance is a variable mode — no naming conflicts. 12 color variables bound consistently.Button, Style=Outline → OutlinedButton, Style=Text → TextButton. SLOT nodes support icon+label compositions. Each size has its own text style — clean native mapping.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | State=Default | All four appearance modes fully defined. |
| Pressed | Yes | Yes | State=Pressed | Darker fill/border using pressed tokens. |
| Disabled | Yes | Yes | State=Disabled | Muted color tokens applied across all appearances. |
| Destructive | Yes | Yes | Appearance mode: Destructive | Red tokens via variable mode. Applies to all 3 styles (Filled/Outline/Text). |
| Focused (a11y) | N/A | N/A | — | Mobile-only component. Focus rings rendered natively by iOS (UIKit/SwiftUI) and Android (Material a11y). No Figma state required. |
| Loading | Yes | Yes | Native modifier | Handled as an interaction modifier in native code — .ebLoading(true) (SwiftUI) / isLoading=true (Compose). Removed as a Figma state in v4.0. |
| Icon Only (a11y) | Yes | Yes | Icon Placement=Icon Only | Square target matches size height. Requires accessibilityLabel / contentDescription since no visible text. |
- Layer renamed from
.base/button/small→containeron compact disabled container (C1) - Icon slots (
Leading Container,Trailing Container) added to all variants as Figma SLOT nodes (C2) isErrorreplaced — Destructive is now an appearance variable mode, not a variant property (C2)- v2: Outlined and Text Link moved from appearance to
Stylevariant property (Filled/Outline/Text) (C2) - v2: Size moved from variable modes to variant dimension — each size has its own text style, eliminating font-size variable conflict (C2/C3)
- v3:
Buttonvariable collection created with 4 appearance modes (Default/Destructive/White/Subtle) — 12 color variables bound to all 60 variants (C3) - v3: Old
Button Sizeandbutton/variantcollections removed (C3) - v3.1: Loading state added — 12 new
State=Loadingvariants with dot indicators replacing label, disabled appearance colors (C5) - v4.0: Icon Placement promoted to component property — replaces
leadingIcon/trailingIconbooleans with a single 4-value enum (None/Leading/Trailing/Icon Only). AddsIcon Onlysquare variant for toolbars/navigation (previously a design recommendation). Handoff is now explicit — developers see icon placement as a first-class property. (C2) - v4.0: Appearance Mode documented in Figma component description with SwiftUI/Compose API mapping — addresses the Mode-invisibility handoff gap until Code Connect is implemented. (C7 partial)
- v4.0: State simplified to Default/Pressed/Disabled — Loading moved to a native interaction modifier rather than a Figma variant. (C5)
- v4.1:
button-containerwrapper layer removed — outermost component now holds fill/radius/auto-layout directly. Layer depth reduced from 4 to 3 (component → container → label/icon). Innercontainerretained for icon-label gap grouping. (C1) - v4.1: Large height reduced from 56px → 50px per design review feedback. (C3)
- v4.1: New Mode-driven token collection applied — all 60 Filled variants bound to
appearance/container/fill(+ pressed/disabled), all 60 Outline variants bound toappearance/stroke/color+ newappearance/label/on-surface/color, all 60 Text variants bound toappearance/label/on-surface/color. Switching the parent frame's Variable Mode (Default / Destructive / White / Subtle) now drives appearance across all 180 variants. (C3) - v4.1: New
appearance/label/on-surface/colorvariable created — semantic separation between labels on filled vs surface backgrounds. Eliminates token-purpose confusion between Filled labels (white-on-fill) and Outline/Text labels (color-on-surface). (C3) - v4.1: Text styles renamed to cleaner
Primary/Label/Large,Primary/Label/Base,Primary/Label/Small,Primary/Label/Fine(wasPrimary/Label/Light/*family). (C3)
- Code Connect mappings not registered. All structural blockers resolved through v4.1 — registration can now proceed against the current API (Style × Appearance × Size + Icon Placement). C7 · Code Connect Linkability
- Document full-width (stretch) behavior. Add an
isFullWidthboolean property for bottom-sheet CTAs and standalone action areas. Today this is achieved via constraints on each screen; a first-class property makes the intent explicit and removes per-screen guesswork. Property
Solid background with contrasting label. Primary action style. Colors change via Appearance variable mode.
Token names resolve to different hex values per mode. All 4 modes share the same 4 variables from the Button collection.
| Mode | Role | Enabled | Pressed | Disabled |
|---|---|---|---|---|
| Default | bg | #005CE5 | #2340A9 | #9BC5FD |
| label | #FFFFFF | #FFFFFF | #FFFFFF | |
| Destructive | bg | #D81E1E | #B01818 | #F5A3A3 |
| label | #FFFFFF | #FFFFFF | #FFFFFF | |
| White | bg | #FFFFFF | #EEF2F9 | #F5F7FA |
| label | #005CE5 | #005CE5 | #005CE5 | |
| Subtle | bg | #E5F1FF | #D2E5FF | #EEF5FF |
| label | #005CE5 | #005CE5 | #005CE5 |
appearance/container/fillappearance/container/fill-pressedappearance/container/fill-disabledappearance/label/colorappearance/label/color-pressedappearance/label/color-disabledTransparent background with border and accent-colored label. Secondary action style.
Outline uses border + label tokens — no background fill. All 4 modes share the same 3 variables from the Button collection.
| Mode | Role | Enabled | Pressed | Disabled |
|---|---|---|---|---|
| Default | border | #005CE5 | #2340A9 | #9BC5FD |
| label | #005CE5 | #2340A9 | #9BC5FD | |
| Destructive | border | #D81E1E | #B01818 | #F5A3A3 |
| label | #D81E1E | #B01818 | #F5A3A3 | |
| White | border | #005CE5 | #2340A9 | #9BC5FD |
| label | #005CE5 | #2340A9 | #9BC5FD | |
| Subtle | border | #005CE5 | #2340A9 | #9BC5FD |
| label | #005CE5 | #2340A9 | #9BC5FD |
appearance/stroke/colorappearance/stroke/color-pressedappearance/stroke/color-disabledappearance/label/on-surface/colorappearance/label/on-surface/color-pressedappearance/label/on-surface/color-disabledNo background or border. Label only. Tertiary action style.
Text style uses label-only tokens — no background or border. All 4 modes share the same 3 variables from the Button collection.
| Mode | Role | Enabled | Pressed | Disabled |
|---|---|---|---|---|
| Default | label | #005CE5 | #2340A9 | #9BC5FD |
| Destructive | label | #D81E1E | #B01818 | #F5A3A3 |
| White | label | #005CE5 | #2340A9 | #9BC5FD |
| Subtle | label | #005CE5 | #2340A9 | #9BC5FD |
appearance/label/on-surface/colorappearance/label/on-surface/color-pressedappearance/label/on-surface/color-disablediOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios" // Or in Package.swift: .package( url: "https://github.com/AY-Org/eb-ds-ios", from: "2.0.0" )
Android — Gradle (Kotlin DSL)
// build.gradle.kts (app) dependencies { implementation("com.eastblue.ds:button:2.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.button.* // Compose
Package not yet published. These are the planned distribution paths. API shape is final — native implementation is pending.
Every row maps a Figma component property to its native equivalent. When a developer selects a variant in Figma, Code Connect will output the corresponding native code using these mappings.
| Figma Property | SwiftUI | Compose |
|---|---|---|
Style=Filled | .ebAppearance(.filled) | EBButton {} |
Style=Outline | .ebAppearance(.outlined) | EBOutlinedButton {} |
Style=Text | .ebAppearance(.textLink) | EBTextButton {} |
Appearance=Default | (default — omit modifier) | (default — omit colors param) |
Appearance=Destructive | .ebColorScheme(.destructive) | colors=EBButtonDefaults.destructiveColors() |
Appearance=White | .ebColorScheme(.white) | colors=EBButtonDefaults.whiteColors() |
Appearance=Subtle | .ebColorScheme(.subtle) | colors=EBButtonDefaults.subtleColors() |
Size=Large…XSmall | controlSize: .large / .regular / .small / .compact / .mini | size=EBButtonSize.Large / Medium / Small / Compact / XSmall |
State=Disabled | .disabled(true) | enabled=false |
(Loading — runtime) | .ebLoading(true) | isLoading=true |
Icon Placement=None | (default — text only) | (default — text only) |
Icon Placement=Leading | Label("…", systemImage: "…") | leadingIcon={ Icon(…) } |
Icon Placement=Trailing | Label + trailing Image | trailingIcon={ Icon(…) } |
Icon Placement=Icon Only | EBButton(icon: Image(…), accessibilityLabel: "…") | EBButton(contentDescription="…") { Icon(…) } |
// Default appearance — Mode resolves at parent (.environment(\.ebAppearance, .default)) EBButton("Save Changes") .ebAppearance(.filled) .controlSize(.large) // Destructive appearance EBButton("Delete Account") .ebAppearance(.filled) .ebColorScheme(.destructive) // Icon Placement = Leading EBButton("Send Money", leadingIcon: Image(systemName: "arrow.up.right")) .ebAppearance(.filled) // Icon Placement = Trailing EBButton("Continue", trailingIcon: Image(systemName: "chevron.right")) .ebAppearance(.filled) // Icon Placement = Icon Only — square target, accessibility label required EBButton(icon: Image(systemName: "plus"), accessibilityLabel: "Add item") .ebAppearance(.filled) // Disabled EBButton("Submit") .ebAppearance(.filled) .disabled(true) // Loading — runtime only, not a Figma state EBButton("Submit") .ebAppearance(.filled) .ebLoading(true)
// Default appearance — Mode resolves at theme/parent EBButton( onClick = { /* action */ }, size = EBButtonSize.Large ) { Text("Save Changes") } // Destructive appearance EBButton( onClick = { /* action */ }, colors = EBButtonDefaults.destructiveColors() ) { Text("Delete Account") } // Icon Placement = Leading EBButton( onClick = { }, leadingIcon = { Icon(Icons.Default.Send, contentDescription = null) } ) { Text("Send Money") } // Icon Placement = Trailing EBButton( onClick = { }, trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null) } ) { Text("Continue") } // Icon Placement = Icon Only — contentDescription required EBButton( onClick = { }, contentDescription = "Add item" ) { Icon(Icons.Default.Add, contentDescription = null) } // Disabled EBButton( onClick = { }, enabled = false ) { Text("Submit") } // Loading — runtime only, not a Figma state EBButton( onClick = { }, isLoading = true ) { Text("Submit") }
// Default EBButton("Cancel") .ebAppearance(.outlined) // Destructive EBButton("Remove Item") .ebAppearance(.outlined) .ebColorScheme(.destructive) // Icon Placement = Leading EBButton("Filter", leadingIcon: Image(systemName: "line.3.horizontal.decrease")) .ebAppearance(.outlined) // Icon Placement = Icon Only EBButton(icon: Image(systemName: "square.and.arrow.up"), accessibilityLabel: "Share") .ebAppearance(.outlined) // Button pair HStack(spacing: 12) { EBButton("Cancel").ebAppearance(.outlined) EBButton("Save").ebAppearance(.filled) }
EBOutlinedButton( onClick = { /* action */ } ) { Text("Cancel") } // Icon Placement = Leading EBOutlinedButton( onClick = { }, leadingIcon = { Icon(Icons.Default.FilterList, contentDescription = null) } ) { Text("Filter") } // Icon Placement = Icon Only EBOutlinedButton( onClick = { }, contentDescription = "Share" ) { Icon(Icons.Default.Share, contentDescription = null) } // Button pair Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { EBOutlinedButton(onClick = {}) { Text("Cancel") } EBButton(onClick = {}) { Text("Save") } }
EBButton("Learn More") .ebAppearance(.textLink) .controlSize(.small) // Destructive EBButton("Remove") .ebAppearance(.textLink) .ebColorScheme(.destructive) // Icon Placement = Trailing (common for inline links) EBButton("Read more", trailingIcon: Image(systemName: "chevron.right")) .ebAppearance(.textLink)
EBTextButton( onClick = { /* action */ }, size = EBButtonSize.Small ) { Text("Learn More") } // Icon Placement = Trailing (common for inline links) EBTextButton( onClick = { }, trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null) } ) { Text("Read more") }
| Requirement | iOS | Android |
|---|---|---|
| Min touch target | 44 × 44pt | 48 × 48dp |
| Focus ring | Handled by UIKit/SwiftUI | Handled by Material ripple |
| Icon-only buttons | .accessibilityLabel("Send") | contentDescription="Send" |
| Destructive role | role: .destructive — announced by VoiceOver | Use semantics { role=Role.Button } |
| Loading state | .accessibilityLabel("Loading") + disable tap | semantics { stateDescription="Loading" } + disable click |
Do
Use one Filled button per screen area as the primary action. Pair with Outline or Text for secondary.
Don't
Place two filled buttons side by side — they compete for attention and neither reads as primary.
Do
Use Destructive appearance for irreversible actions (delete, remove). Always pair with a confirmation.
Don't
Use Destructive for actions that are simply "negative" but reversible (dismiss, close, decline).
Do
Use White appearance on brand-colored or dark surfaces (hero banners, promotional cards).
Don't
Use White appearance on a white background — the button disappears. Use Default or Subtle instead.
Do
Use Text style for inline or low-emphasis actions (Learn more, View terms, Skip).
Don't
Use Text style for primary form submission — it lacks the visual weight to signal the main action.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | All layers use semantic names. container, #label, leadingIcon, trailingIcon consistent across all 60 variants. |
| C2 | Variant & Property Naming | Ready | v3 clean orthogonal dimensions: Style (Filled/Outline/Text), Size (Large/Medium/Small/Compact/XSmall), State (Default/Pressed/Disabled). Appearance via 4 variable modes. leadingIcon/trailingIcon are SLOT nodes with Boolean component properties. |
| C3 | Token Coverage | Ready | All color values connected to semantic tokens. Layout/sizing driven by button/size variable collection (height, padding-h, padding-v, font-size). |
| C4 | Native Mappability | Ready | Maps to Button, OutlinedButton, TextButton. Destructive maps to role: .destructive / contentColor=errorColor. |
| C5 | Interaction State Coverage | Ready | Default, Pressed, Disabled, Loading covered across all 60 variants. Focus ring is N/A — mobile OS handles natively. Loading uses dot indicators with disabled appearance colors. |
| C6 | Asset & Icon Quality | Ready | Icon slots are Figma SLOT nodes accepting vector icon instances. Boolean properties control visibility. |
| C7 | Code Connect Linkability | Needs Refinement | No CLI mappings registered yet. Property structure is clean and ready for mapping. |
Maps v3.2 variant dimensions (Style × Size × State) and variable modes (Appearance) to native parameters. 60 variants × 4 appearance modes=240 visual states.
| Figma | SwiftUI | Compose |
|---|---|---|
| Filled | .ebAppearance(.filled) | EBButton {} |
| Outline | .ebAppearance(.outlined) | EBOutlinedButton {} |
| Text | .ebAppearance(.textLink) | EBTextButton {} |
| Figma | SwiftUI | Compose |
|---|---|---|
| Large 52px | .controlSize(.large) | size=EBButtonSize.Large |
| Medium 36px | .controlSize(.regular) | size=EBButtonSize.Medium |
| Small 28px | .controlSize(.small) | size=EBButtonSize.Small |
| Compact 28px | .controlSize(.compact) | size=EBButtonSize.Compact |
| XSmall 24px | .controlSize(.mini) | size=EBButtonSize.XSmall |
| Figma | SwiftUI | Compose |
|---|---|---|
| Default | (default) | (default) |
| Pressed | (system) | (system) |
| Disabled | .disabled(true) | enabled=false |
| (Loading — runtime) | .ebLoading(true) | isLoading=true |
| Figma | SwiftUI | Compose |
|---|---|---|
| Default | (omit — default) | (omit — default) |
| Destructive | .ebColorScheme(.destructive) | colors=…destructiveColors() |
| White | .ebColorScheme(.white) | colors=…whiteColors() |
| Subtle | .ebColorScheme(.subtle) | colors=…subtleColors() |
| Figma | SwiftUI | Compose |
|---|---|---|
| None | (text only — default) | (text only — default) |
| Leading | leadingIcon: Image(…) | leadingIcon={ Icon(…) } |
| Trailing | trailingIcon: Image(…) | trailingIcon={ Icon(…) } |
| Icon Only | EBButton(icon:, accessibilityLabel:) | EBButton(contentDescription=…) { Icon(…) } |
3 Style × 5 Size × 3 State × 4 Icon Placement=180 variants. Appearance is a variable mode (Default/Destructive/White/Subtle) that further multiplies visual states × 4=720 resolved visual states.
| Style | Sizes | States | Icon Placements | Count |
|---|---|---|---|---|
| Filled | Large, Medium, Small, Compact, XSmall | Default, Pressed, Disabled | None, Leading, Trailing, Icon Only | 60 |
| Outline | Large, Medium, Small, Compact, XSmall | Default, Pressed, Disabled | None, Leading, Trailing, Icon Only | 60 |
| Text | Large, Medium, Small, Compact, XSmall | Default, Pressed, Disabled | None, Leading, Trailing, Icon Only | 60 |
View full Style × Size breakdown (15 rows)
| Style | Size | Height | States × Icon Placements | Count |
|---|---|---|---|---|
| Filled | Large | 50px | 3 × 4 | 12 |
| Filled | Medium | 48px | 3 × 4 | 12 |
| Filled | Small | 36px | 3 × 4 | 12 |
| Filled | Compact | 28px | 3 × 4 | 12 |
| Filled | XSmall | 24px | 3 × 4 | 12 |
| Outline | Large | 50px | 3 × 4 | 12 |
| Outline | Medium | 48px | 3 × 4 | 12 |
| Outline | Small | 36px | 3 × 4 | 12 |
| Outline | Compact | 28px | 3 × 4 | 12 |
| Outline | XSmall | 24px | 3 × 4 | 12 |
| Text | Large | 50px | 3 × 4 | 12 |
| Text | Medium | 48px | 3 × 4 | 12 |
| Text | Small | 36px | 3 × 4 | 12 |
| Text | Compact | 28px | 3 × 4 | 12 |
| Text | XSmall | 24px | 3 × 4 | 12 |
appearance/container/fill (and pressed/disabled), Outline borders bound to appearance/stroke/color, all Outline + Text labels bound to new appearance/label/on-surface/color. Switching the parent frame's Variable Mode now drives appearance across the entire variant set. Validates the Mode → Property → API translation pattern for upcoming Code Connect work. Appliedappearance/label/on-surface/color variable created — 3 variants (color, color-pressed, color-disabled) × 4 modes. Provides semantic separation: label/color=labels on filled backgrounds (white-on-fill), label/on-surface/color=labels on transparent/surface backgrounds (color-on-surface). Eliminates the binding ambiguity for Outline/Text styles. Addedbutton-container wrapper layer removed — Visual properties (fill, radius, auto-layout, padding) lifted from inner button-container frame up to the variant component itself. Layer depth: 4 → 3. Native parity improved (the component IS the styled element, matching SwiftUI/Compose conventions). Inner container frame retained for icon-label gap grouping. RestructuredPrimary/Label/Large (was Primary/Label/Light/Base), Primary/Label/Base, Primary/Label/Small, Primary/Label/Fine. Cleaner semantic naming, removes the redundant "Light" prefix. RenamedleadingIcon, trailingIcon) caused handoff ambiguity. Now a single 4-value enum: None / Leading / Trailing / Icon Only. Adds Icon Only as a new square-button variant. Total variants: 60 → 180 (3 Styles × 3 States × 5 Sizes × 4 Icon Placements). RestructuredState now Default/Pressed/Disabled. Loading is handled as an interaction modifier in native code rather than a Figma variant. SimplifiedState=Loading variants (3 Styles × 4 Sizes). Dot indicators (● ● ●) replace label text. Uses disabled appearance colors. Tap is disabled during loading. AddedisError replaced with Variant: Brand | Destructive — True orthogonal property applied to all 24 variants. Destructive Default (filled) variants added for all 3 states. All 30 existing variants renamed. Fixedbutton/size variable collection with 4 modes: Large (52px), Medium (36px), Small (28px), XSmall (24px). Reduces variant count from 36 → 24 while expanding size coverage. Restructuredbutton/size variable collection created — 5 variables (height, font-size, padding-h, padding-v, icon-size) bound to all containers, labels, and icon slots across all variants. Fixed height binding prevents icon slot size from affecting button height. AddedleadingIcon and trailingIcon promoted from hidden frames to Figma SLOT nodes. Boolean component properties added for designer toggle control. UpgradedisError is not a true orthogonal boolean. Only applies to Outlined and Text Link, not Default. Recommendation: fold into Appearance as Outlined Error / Text Link Error. Resolved in v2.0.0. Resolved in 2.0.0leadingIcon + trailingIcon — Added to all 30 variants. Hidden by default. Upgraded to SLOT nodes with Boolean properties in v2.0.0. Fixed.base/button/small → container — Resolves C1. FixedA 140-wide vertical carousel card for showcasing articles, products, or profiles. Container holds a 140×140 banner image slot, a 12px gap, then a content block with an 18/23 title and a 12/18 two-line description. 3 Figma variants: default (banner + content), with icon (banner + gradient shadow + circular icon badge + content), and skeleton loader (placeholder bars). Intended to sit inside a horizontal scroller of peer cards.
type property conflates a content variant (default vs with icon) with a loading state (skeleton) — these should be orthogonal axes. Banner image, dimmer, and icon badge are all hardcoded placeholders instead of instance slots. No pressed/focused state despite the card being tappable. Most importantly, this component is 1 of 5 near-duplicate "carousel card / item" components that should consolidate to 1–2 canonical primitives.Carousel Card lives in a horizontal scroller — typically a "Featured" or "For You" rail on a home or category screen. Cards are peeked (part of the next one visible) to signal scrollability.
Flip type across the three variants to see how the banner, icon badge, and skeleton treatments render. Title and description are editable to test copy fit at the 140px width.
#e6e1ef multiply dimmer + a hardcoded #c2c6cf icon circle. None of these are instance slots, so consumers can't swap media or icons cleanly.type enum mixes content axes with a loading state. Naming diverges from the sibling Carousel - Item family, which uses position and a different anatomy.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | type=default | Banner image + title + 2-line description. No overlay. |
| With icon | Yes | Yes | type=with icon | Adds a bottom gradient shadow on the banner and a circular icon badge (30×30, blue fill) at the bottom-left of the banner. |
| Skeleton (loading) | Yes | Yes | type=skeleton loader | Gray block for the banner; bar placeholders for title + 2 description lines. |
| Pressed | Missing | Missing | Not built | Card is tappable (navigates to detail) — needs a pressed state: scale-down 0.98 or a subtle bg tint. |
| Focused | Missing | Missing | Not built | Keyboard/D-pad navigation — needs an outline ring for TV/tablet surfaces and accessibility. |
typeenum conflates a content variant with a loading state.defaultandwith icondescribe content shape;skeleton loaderdescribes a loading state. Split into two orthogonal axes:variant=default | with-iconandisLoading: Boolean(or a siblingCarouselCardSkeletoncomponent). C2 · Variant & Property Naming- Banner is a hardcoded raster placeholder. Ships a static
replace-this-assetPNG plus a purple#e6e1efmix-blend-multiplydimmer layer. Neither is a Figma Slot — designers must detach and redraw to swap media. Expose an image slot and drop the fixed dimmer (tint should be optional + tokenized). C6 · Asset & Icon Quality - Icon badge on "with icon" variant is a filled circle, not an Icon instance. A
#c2c6cf24×24 circle sits inside a 30×30 blue pill — there's no instance swap, so brand icons, service glyphs, or Avatars can't be dropped in without detaching. C6 · Asset & Icon Quality - No pressed or focused state. Carousel cards are tappable and navigate somewhere — pressed feedback (scale or tint) is expected, and focused is needed for keyboard/D-pad surfaces. Only Default + skeleton are modeled today. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked until the
typesplit, the image/icon slots, and the family consolidation land. C7 · Code Connect Linkability
- Consolidate Carousel Card + Carousel - Discount Card into a single
Carousel Card. The two are the same anatomy — 140-wide vertical card with banner + block-content — differing only in visual treatment. Merge into one component with avariantprop (default/discount) and let the discount-specific overlay (price tag, strikethrough, etc.) live as an overlay slot. Today's 5-component family collapses to 2:Carousel CardandCarousel Item. Family - Consolidate Carousel - Item + Carousel Item - Center + Carousel Item - Side into a single
Carousel Item. "Center" and "side" describe a peek carousel's runtime layout position, not a component variant — a carousel layout computes which item is center vs side, it shouldn't be baked into the component as an enum. Merge these 3 into oneCarousel Itemand let the parent carousel apply the peek transform. Family - Split
typeintovariant+isLoading.variant=default | with-icondescribes content shape.isLoading: Boolean(or a dedicatedCarouselCardSkeletonsibling) describes the loading state. Keeps content and state axes orthogonal and matches how Generic Card proposes to handle skeleton. Property - Adopt Figma Slots for banner and icon badge. Banner becomes an image slot accepting any frame; icon badge becomes an Icon / Avatar instance slot. Native maps banner →
AsyncImage/AsyncImageslot and icon →@ViewBuilder(SwiftUI) or@Composable(Compose). Slot - Replace the hardcoded purple dimmer with an optional tint token. The
#e6e1efmix-blend-multiplylayer is a loudly-colored overlay that shouldn't ship as a default on every banner. Make it an optionaloverlayprop bound tomain/carousel/color/overlay(currently unbound). Token - Add pressed + focused states. Pressed: scale 0.98 or bg tint on the full card. Focused: 2px outline ring in
border/focus. Tappable components need both. State - Rename to clarify hierarchy. After consolidation,
Carousel Card(this component + Discount Card) handles the full-width banner pattern;Carousel Item(peek variants) handles the peek pattern. Avoid overlap in naming between the two. Rename - Document the skeleton treatment as a shared DS convention. Carousel Card, Generic Card, and other card primitives all ship first-class skeletons — call out the pattern in the guidelines so card-family consistency holds as more components adopt it. Docs
- See siblings:Generic Card (horizontal list row) — same "card + skeleton + tappable" pattern, different layout. Keep skeleton treatment aligned across both. Family
23:121312Banner image + title + 2-line description. The banner ships a placeholder PNG dimmed by a purple multiply layer — replace both with your real media.
140140 × 1404 (radius/radius-1)124215| ROLE | TOKEN | VALUE |
|---|---|---|
| Banner placeholder | (raster PNG) | replace-this-asset |
| Banner dimmer | (not tokenized) | #E6E1EF @ 40% multiply |
| Title | carousel/label-header | #2340A9 |
| Description | carousel/description | #6780A9 |
| Surface | bg/color-bg-main | #FFFFFF |
Primary/Headlines/BlockProxima Soft Bold · 18 / 23 · +0.25Secondary/Bold/CaptionBarkAda Semibold · 12 / 18 · 023:121322Default layout + a bottom-left icon badge on the banner. A gradient shadow along the lower third improves icon contrast against bright imagery.
left 12, top 9430 × 30 (5 padding)59 (pill)24 × 240 → 40% black, 73 px tallmultiply| ROLE | TOKEN | VALUE |
|---|---|---|
| Icon badge bg | bg/color-bg-primary | #005CE5 |
| Icon glyph (today) | (not tokenized) | #C2C6CF |
| Shadow | (hardcoded gradient) | #040506 fade |
23:121334Loading placeholder: banner becomes a flat light-gray block; title and description become bar placeholders. Card total height drops to 212 (vs 215 default) due to the 16 top gap in the content block.
140 × 140 (flat)16140 × 16 · r=4140 × 10 · r=497 × 10 · r=48| ROLE | TOKEN | VALUE |
|---|---|---|
| Banner fill | bg/color-bg-strong | #EEF2F9 |
| Bar fill | bg/color-bg-strong | #EEF2F9 |
| Bar radius | radius/radius-1 | 4 |
.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBCarouselCard
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
type: default | with icon | skeleton loader | variant: default | with-icon+isLoading: Boolean | variant: EBCarouselCardVariant+isLoading: Bool | variant: EBCarouselCardVariant+isLoading: Boolean |
| (hardcoded raster) | image: Frame (slot) | image: () -> Image | image: @Composable () -> Unit |
| (hardcoded text) | title: String | title: String | title: String |
| (hardcoded text) | description: String | description: String? | description: String? |
| (hardcoded circle) | icon?: Icon (slot) | icon: EBIcon? | icon: @Composable (() -> Unit)? |
| (hardcoded purple multiply) | overlay?: Color (token) | overlay: Color? | overlay: Color? |
| (not modeled) | onTap?: () -> Void | onTap: (() -> Void)? | onClick: (() -> Unit)? |
ios/Components/CarouselCard/EBCarouselCard.swiftios/Components/CarouselCard/EBCarouselCardSkeleton.swiftandroid/components/carouselcard/EBCarouselCard.ktandroid/components/carouselcard/EBCarouselCardSkeleton.kt
// Horizontal rail of cards ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { EBCarouselCard( title: "Featured Article", description: "A short teaser for this item.", image: { AsyncImage(url: article.coverURL) }, onTap: { openArticle(article) } ) // With-icon variant EBCarouselCard( variant: .withIcon, title: "Send Money", description: "Locally or abroad, same day.", image: { AsyncImage(url: promo.cover) }, icon: EBIcon(.sendMoney), onTap: { openPromo(promo) } ) // Loading state EBCarouselCardSkeleton() } .padding(.horizontal, 16) }
// Horizontal rail of cards LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(horizontal = 16.dp) ) { item { EBCarouselCard( title = "Featured Article", description = "A short teaser for this item.", image = { AsyncImage(article.coverURL, contentDescription = null) }, onClick = { openArticle(article) } ) } item { EBCarouselCard( variant = EBCarouselCardVariant.WithIcon, title = "Send Money", description = "Locally or abroad, same day.", image = { AsyncImage(promo.cover, contentDescription = null) }, icon = { EBIcon(EBIcons.SendMoney) }, onClick = { openPromo(promo) } ) } item { EBCarouselCardSkeleton() } }
| Requirement | iOS | Android |
|---|---|---|
| Card as a button | Whole card wrapped in Button with combined accessibilityLabel (title + description). | Modifier.clickable { onClick() }.semantics(mergeDescendants=true) on the column. |
| Combined announcement | "Send Money, Locally or abroad, same day" — VoiceOver reads title then description. | Same reading order — TalkBack follows composition. |
| Image alt | If the banner carries meaning, pass accessibilityLabel on the image; otherwise mark as decorative. | contentDescription set when banner is content-bearing, null when decorative. |
| Min touch target | Card is 140×215 — comfortably above 44 pt ✓ | 140 dp × 215 dp — above 48 dp ✓ |
| Loading state | Announce "Loading" once on mount; suppress per-placeholder announcements. | contentDescription="Loading" on the skeleton container. |
| Focus ring | Add a .focused() modifier → 2 px outline for tvOS/iPad keyboard. | Modifier.focusable() + border in border/focus token. |
- Use inside a horizontal scroller — never as a standalone card.
- Keep titles short (1 line) — the 140 width only fits ~10–12 characters at 18 / 23.
- Show the skeleton while data loads — don't show blank cards.
- Use the with-icon variant when the card represents a service (icon signals category).
- Don't ship the default purple
#E6E1EFdimmer on real imagery — make overlays opt-in per card. - Don't use this for peek-style carousels — use Carousel Item once it's consolidated.
- Don't mix with-icon and default cards in the same rail — pick one treatment per scroller.
- Don't hardcode the banner image in Figma — use the image slot once adopted.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Clean container / banner / block-content hierarchy. Layer names are semantic. |
| C2 | Variant & Property Naming | Needs Refinement | type conflates content variant with loading state — split into variant + isLoading. |
| C3 | Token Coverage | Needs Refinement | Title / description / skeleton fills bound to tokens. Banner PNG, purple dimmer, and icon glyph color are not. |
| C4 | Native Mappability | Needs Refinement | Maps cleanly to VStack / Column in a horizontal scroller once image/icon slots and skeleton split land. |
| C5 | Interaction State Coverage | Needs Refinement | Default + skeleton built. Missing pressed + focused. |
| C6 | Asset & Icon Quality | Needs Refinement | Banner is a raster placeholder PNG; icon is a drawn circle, not an Icon instance; purple dimmer is hardcoded. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on property split, slot adoption, and family consolidation. |
type (3)=3 variants. default and with icon share the same overall dimensions (140 × 215); skeleton loader is 140 × 212 due to a different content padding.
| type | Node | Dimensions | Anatomy |
|---|---|---|---|
| default | 23:121312 | 140 × 215 | Banner + title + 2-line description. |
| with icon | 23:121322 | 140 × 215 | Default + gradient shadow + bottom-left 30×30 icon badge on banner. |
| skeleton loader | 23:121334 | 140 × 212 | Flat banner + 3 bar placeholders (title 16, desc 10, desc 10 @ 97w). |
type into variant + isLoading, adopt image + icon slots, drop the hardcoded purple dimmer, add pressed/focused states, and consolidate the 5-component Carousel family. OpenEBCarouselCardSkeleton. NotedCarousel Card (default / discount) and Carousel Item (peek). Openvariant for content, isLoading for state. OpenA voucher/discount carousel item — 140 px wide frame with a 140 × 152 banner image (perforated bottom edge), two-line label, and a peso-value line below. 3 Figma variants across type: default, with violator (adds a blue tag in the banner's top-right corner), and skeleton loader. Anatomy is nearly identical to Carousel Card — same width, same banner + text stack, same skeleton pattern.
variant=discountCarousel Card rather than a separate component. Today's 3 variants collapse into Carousel Card props: variant: default | with-icon | discount, violator?: string, isLoading: bool.Discount Card appears in horizontally-scrolling voucher rails — GDeals, Voucher Pocket, "For You" promotions. Violator tag calls out freshness (New, Ending Soon, Limited).
Type your own label and value. Toggle the violator tag and flip between Default and Skeleton to see the loading pattern.
_space_12 layer acts as a 12 px spacer via an invisible rectangle rather than a gap token.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | type=default | Banner + perforate + label + value. No violator. |
| With violator | Yes | Yes | type=with violator | Same layout with a blue tag anchored to the banner's top-right corner. Text is hardcoded "New". |
| Skeleton (loading) | Yes | Yes | type=skeleton loader | Flat 140 × 152 placeholder fill for the banner; rounded rectangles for title (27 h) and amount (10 h × 101 w). |
| Pressed | Missing | Missing | Not built | Cards tap through to voucher detail — needs a pressed state (subtle dim or scale) for feedback. |
- Duplicates Carousel Card's anatomy. Same 140-wide frame, same banner + text + skeleton composition. Ships as a second component with its own variants and tokens instead of a
variant=discounton the shared card. C1 · Layer Structure & Naming typeconflates layout and state.defaultandwith violatorare layout variants;skeleton loaderis a loading state. Packing them on one enum forces mutually-exclusive combinations that shouldn't be — a violator card can also be loading. C2 · Variant & Property Naming- Violator label hardcoded. The "New" string is baked into the variant — consumers can't show "Ending Soon", "Limited", or localized copy without detaching. C2 · Variant & Property Naming
- Perforated voucher edge baked into the banner image. The serrated bottom is part of a raster PNG, not a vector overlay. Ties every "discount card" to voucher visuals even when the use case is a plain promo card. C4 · Native Mappability
- Banner is a raster PNG with mask layers.
replace-this-asset+ mask intersect + overflow-clip is fragile on native (iOSAsyncImage/ ComposeAsyncImagedon't need any of that). Also blocks the image from being sized/cropped consistently. C6 · Asset & Icon Quality _space_12invisible rectangle used as a spacer. A 10.305 px tall#0500ffrectangle withopacity:0sits between label and value as a spacing hack. Should be agap/space-*token on the auto-layout. C1 · Layer Structure & Naming- No pressed state. Card is tappable (opens voucher detail) but no pressed/active appearance is modeled. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked until the consolidation into Carousel Card is decided. C7 · Code Connect Linkability
- Consolidate into Carousel Card with
variant=discount. The entire Carousel family (Carousel Card, Carousel - Discount Card, Carousel - Item, Carousel Item - Center, Carousel Item - Side) shares a 140-wide frame and banner + text + skeleton composition. Merge the three "card" siblings into oneCarousel Cardwithvariant: default | with-icon | discount. Preserves every existing layout; eliminates redundant components. Family - Split
typeinto independent props. On the consolidatedCarousel Card:variant: default | with-icon | discount(layout),violator?: string(optional slot — any text, any variant),isLoading: bool(orthogonal state). 3 × 2 × 2 visual cases from 3 clean props instead of conflated enums. Property - Make the violator a named slot. Adopt Figma Slots for the top-right corner overlay. Accepts Badge instance or custom text — maps to
@ViewBuilder(SwiftUI) /@Composableslot (Compose) via Code Connect. Slot - Replace the perforate edge with a vector overlay. Today it's baked into the banner raster. Extract as a vector SVG rendered on top of the banner when
variant=discount. Token-bindable fill + crisp at any scale. Asset - Remove the
_space_12placeholder rectangle. Usegap: 12on the content auto-layout frame instead. Invisible elements used as spacers are a C1 anti-pattern — they clutter the layer tree and break native handoff. Property - Rename the value slot to match Figma's token. The peso amount binds to
main/carousel/color/valuebut renders as a standalone text. Expose asamount: Stringon the proposed Carousel Card so Code Connect can target it directly. Rename - Add a pressed state on the consolidated card. Subtle scale (0.98) or overlay tint when tapped. One state that covers all three variants on the merged component. State
- Banner should accept an Image instance, not a mask layer. Replace the
Asset Placeholder+replace-this-asset+ mask stack with a single image-fill slot on the banner frame. Cleaner handoff toAsyncImageon both platforms. Slot
18543:2762Voucher card with perforated banner image, two-line label, and peso-value line.
140 × 223.48140 × 1526.87 10.305 10.305 10.30540 1 3 0 rgba(232,238,242,.79)bottom 43.8 · raster PNG| ROLE | TOKEN | VALUE |
|---|---|---|
| Content bg | bg/color-bg-main | #FFFFFF |
| Label | main/carousel/color/label | #0A2757 |
| Value | main/carousel/color/value | #2340A9 |
| Skeleton fill | bg/color-bg-strong | #EEF2F9 |
| Violator bg | bg/color-bg-primary | #005CE5 |
| Violator label | text/color-text-inverse | #FFFFFF |
Primary/Multi-line Label/SmallProxima Soft Bold · 14 / 16 · +0.25Primary/Label/FineProxima Soft Bold · 12 / 12 · +0.5Primary/Label/FineProxima Soft Bold · 12 / 12 · +0.518543:2770Adds a blue violator tag anchored to the banner's top-right corner. Text is hardcoded "New" today — should be a parameterized slot.
top: 10.31 · right: -0.313.435 5.1534 (bl, tl only)18543:2782Loading pattern: flat banner fill, rounded title rectangle, shorter amount rectangle. Centered column (differs from the left-aligned default).
140 × 152full-width × 27101 × 104 (all)14 (between title & amount).package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBCarouselCard
| Figma (today) | Figma (proposed, on Carousel Card) | SwiftUI | Compose |
|---|---|---|---|
type=default | variant: discount | .ebVariant(.discount) | EBCarouselCard(variant=Discount, …) |
type=with violator | violator?: String (slot) | violator: String? / violatorSlot: (()->Badge)? | violator: @Composable (() -> Unit)? |
type=skeleton loader | isLoading: Bool | loading: Bool | loading: Boolean |
| (hardcoded 2-line label) | label: String (2-line auto-wrap) | label: String | label: String |
| (hardcoded "PHP 200.00") | amount: String | amount: String | amount: String |
| (mask + raster asset) | banner: Image (slot) | banner: Image | banner: @Composable (() -> Unit) |
| (baked perforate PNG) | (auto-rendered vector when variant=discount) | — | — |
(_space_12 invisible rect) | (auto-layout gap token) | — | — |
| (not modeled) | onTap?: () -> Void | onTap: (() -> Void)? | onClick: (() -> Unit)? |
ios/Components/CarouselCard/EBCarouselCard.swift(consolidated — no separate EBCarouselDiscountCard)android/components/carouselcard/EBCarouselCard.kt
// Discount card (voucher) — default EBCarouselCard( variant: .discount, banner: Image("voucher-hero"), label: "2% off GCrypto\nBitcoin purchase", amount: "PHP 200.00" ) { openVoucher() } // With violator tag EBCarouselCard( variant: .discount, banner: Image("voucher-hero"), violator: "New", label: "2% off GCrypto\nBitcoin purchase", amount: "PHP 200.00" ) { openVoucher() } // Loading state EBCarouselCard.skeleton(variant: .discount)
// Discount card (voucher) — default EBCarouselCard( variant = EBCarouselVariant.Discount, banner = { AsyncImage("voucher-hero") }, label = "2% off GCrypto\nBitcoin purchase", amount = "PHP 200.00", onClick = { openVoucher() } ) // With violator tag EBCarouselCard( variant = EBCarouselVariant.Discount, banner = { AsyncImage("voucher-hero") }, violator = { EBBadge("New", state = EBBadgeState.Primary) }, label = "2% off GCrypto\nBitcoin purchase", amount = "PHP 200.00", onClick = { openVoucher() } ) // Loading state EBCarouselCard.Skeleton(variant = EBCarouselVariant.Discount)
| Requirement | iOS | Android |
|---|---|---|
| Card as a button | Wrap in Button; accessibilityLabel combines violator + label + amount. | Modifier.clickable { onTap() }.semantics(mergeDescendants=true) on the card. |
| Combined announcement | "New, 2% off GCrypto Bitcoin purchase, PHP 200.00" | Same reading order via TalkBack. |
| Loading state | Announce "Loading voucher" once on mount; suppress placeholder reads. | contentDescription="Loading voucher" on the skeleton container. |
| Min touch target | 223 pt card height ≫ 44 pt ✓ | 223 dp ≫ 48 dp ✓ |
- Use for voucher / discount / coupon entries in a horizontal rail.
- Show the skeleton variant while voucher data loads.
- Use the violator to call out freshness or urgency ("New", "Ending Soon").
- Pair with a "See all" link at the end of the rail for large voucher lists.
- Don't use for non-voucher content — use Carousel Card (
variant=default) instead. - Don't stack vertically — this is a horizontal-rail item.
- Don't overload the violator — one tag per card.
- Don't use more than 2 lines in the label — overflow truncates.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Needs Refinement | _space_12 invisible rectangle acts as a spacer; Asset Placeholder + replace-this-asset leak authoring affordances. |
| C2 | Variant & Property Naming | Needs Refinement | type conflates layout and loading state; violator text hardcoded. |
| C3 | Token Coverage | Ready | All colors bound to main/carousel/color/*, bg/*, text/*. Typography via Primary/Multi-line Label/Small + Primary/Label/Fine. |
| C4 | Native Mappability | Needs Refinement | Perforate edge baked into the banner raster; mask-intersect image composition doesn't translate cleanly to AsyncImage. |
| C5 | Interaction State Coverage | Needs Refinement | Default + skeleton built. Missing pressed for a tappable card. |
| C6 | Asset & Icon Quality | Needs Refinement | Banner + perforate ship as raster PNGs. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until consolidation into Carousel Card is decided. |
type (3)=3 variants. Single-axis enum conflates layout (default, with violator) with loading state (skeleton loader) — should split into variant + violator? + isLoading on consolidation.
| type | Node | Dimensions | Notes |
|---|---|---|---|
| default | 18543:2762 | 140 × 223.48 | Banner + label + value. Left-aligned. |
| with violator | 18543:2770 | 140 × 223.48 | Adds blue "New" tag top-right of banner. |
| skeleton loader | 18543:2782 | 140 × 211 | Flat banner fill + 2 rounded rect placeholders. Centered column. |
variant=discount + violator? slot + isLoading. Anatomy duplicates Carousel Card. Open_space_12 invisible spacer — Replace with gap: 12 on auto-layout. Opentype conflates layout + state — Split into variant, violator?, isLoading on consolidated Carousel Card. Parameterize violator text. OpenA peeking-carousel banner item — 282×160 card with a raster background image, a text stack (optional preamble, heading, optional description), and an inline button link (optional). 10 Figma variants across mode (Light Text / Dark Text) × type (Default / with Icon / Headline Only) × hasTextLink × hasPreamble. Duplicated as Carousel Item - Center (312×160) and Carousel Item - Side (312×146) with identical schemas.
Carousel Item (or fold into Carousel Card), strip mode in favour of a proper appearance mode set, replace the raster background with a background slot, vectorize the chevron, and add pressed state.Carousel - Item is one card in a horizontal swipe carousel — typically a promotional banner stack on the Home or Dashboard screen. The center item is emphasized; side items peek in at reduced opacity/scale. In today's Figma file, those visual states exist as separate components (Item, Item - Center, Item - Side) rather than being driven by the carousel container.
Edit the copy and flip the property toggles to see how the item reflows. The component has a raster background today — the preview uses a flat gradient placeholder.
hasPreamble vs with Icon). type=Headline Only is actually "with Preamble + Heading Only" — mis-named.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | mode × type × hasTextLink × hasPreamble | Static banner with tap target on the whole card (if hasTextLink) or on the button only. |
| Focused (center) | Separate component | Separate component | Carousel Item - Center (312×160) | Modeled as a sibling component. Should be a scroll-progress-driven style inside the carousel container. |
| Peeking (side) | Separate component | Separate component | Carousel Item - Side (312×146) | Modeled as a sibling component. Should be a scroll-progress-driven style inside the carousel container. |
| Pressed | Missing | Missing | Not built | Banner is tappable — needs a subtle scale-down or overlay tint on press. |
| Disabled | N/A | N/A | — | Promotional banner — disabled state isn't meaningful. |
- Position is a separate component, not a runtime style. Carousel - Item (282), Carousel Item - Center (312×160), and Carousel Item - Side (312×146) all share the same 10-variant schema. Center/side should be carousel-container-driven visual states via scale + opacity on scroll progress — not distinct DS components. C1 · Layer Structure & Naming
- Property casing is inconsistent. Booleans are camelCase (
hasPreamble,hasTextLink) but enum values are Title-Cased or hyphenated with lowercase (Light Text,with Icon,Headline Only). Pick one convention. C2 · Variant & Property Naming type=Headline Onlyis misleading. That variant renders Preamble + Heading + Button (no description). Either rename tonoDescriptionor collapse into a booleanhasDescription. C2 · Variant & Property Namingmodeis about text color, not appearance. The enum decides text color only (inverse on dark photos vs dark on light photos). Native platforms should infer contrast from the background image or expose a properappearanceenum — not a layer-level token swap. C4 · Native Mappability- Background image has no slot. The image is baked into the variant — product teams can't drop in their own artwork without detaching the instance. C4 · Native Mappability
- Chevron is a raster
shape_fullPNG. Doesn't scale cleanly and can't accept a token-bound tint. Ships twice (once permode). C6 · Asset & Icon Quality - Icon placeholder in
with Iconis a drawn grey circle. Not a swappable Icon or Avatar instance. C6 · Asset & Icon Quality - No pressed state. Banner is tappable but only Default is modeled. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked until the three siblings are consolidated and the image + icon slots are adopted. C7 · Code Connect Linkability
- Consolidate Carousel - Item + Center + Side into a single
Carousel Item. All three share the same 10-variant schema; only dimensions differ. Pick one canonical size (312×160 is the most common) and let the carousel container handle center-vs-side styling via scroll progress. Preferred target: fold into Carousel Card if content shape is compatible, otherwise keep asEBCarouselItemsibling. Family - Let the carousel container own focus/peek styling. Center-item scale, side-item opacity, and peek offset are layout concerns — not component variants. On iOS use
.scrollTransition { content, phase in content.opacity(phase.isIdentity ? 1 : 0.6).scaleEffect(phase.isIdentity ? 1 : 0.94) }. On Android usegraphicsLayerkeyed offHorizontalPagerpage offset. Composition - Add a
backgroundslot. Replace the baked-in raster with a Figma Slot that accepts an image, gradient, or illustration instance. Native:background: AnyView(SwiftUI) /background: @Composable () -> Unit(Compose). Slot - Replace
modewith anappearanceenum.appearance: light | dark(matching Button's conventions) — picks the full text/link color set in one go rather than Figma overriding each layer. Later, auto-derive from background luminance if tooling supports it. Property - Rename
type=Headline OnlytohasDescription: false. The current name doesn't describe what that variant renders. CollapsetypetoDefault | with Iconand move description visibility to a boolean. Rename - Add a leading icon slot (Icon or Avatar). Replace the grey circle placeholder with a proper Figma Slot that accepts an Icon or Avatar instance. Slot
- Vectorize the chevron. Swap the raster
shape_fullfor a vector glyph — one instance, token-bound color, crisp at any scale. Asset - Add pressed state. Subtle scale-down (0.98) or dark overlay (6–8% black) on press — banners are tappable and need tap feedback. State
- Document the carousel container. The peek behaviour, snap-to-center, page indicator, and auto-advance belong on a dedicated
EBCarouselcontainer component — not in each item. Family - Announce as a link / button. The whole card is tappable — VoiceOver and TalkBack should read heading + description + "Button" as a single actionable announcement. A11y
18543:2807The most common variant — heading + description + button link, inverse text over a dark background image. Used on promotional carousels when the photo has dark tones.
282 × 16018 208 (radius/radius-3)12916 (space/space-16)0 8 12 -8 · rgba(2,14,34,0.16)24 × 24| MODE | ROLE | TOKEN | DEFAULT |
|---|---|---|---|
| Light Text | Heading | text/color-text-inverse | #FFFFFF |
| Description | text/color-text-inverse | #FFFFFF | |
| Preamble | text/color-text-inverse-weak | #F6F9FDCC | |
| Button link | text/color-text-inverse | #FFFFFF | |
| Dark Text | Heading | main/carousel/color/label | #0A2757 |
| Description | main/carousel/color/description | #6780A9 | |
| Preamble | text/color-text-stronger @ 60% | #072592 | |
| Button link | text/color-text-primary | #005CE5 | |
| — | Card bg fallback | main/carousel/color/placeholder | #FFFFFF |
Primary/Headlines/BlockProxima Soft Bold · 18 / 23+0.25 (tracking-wide)Secondary/Bold/CaptionBarkAda Semibold · 12 / 18Primary/Label/FineProxima Soft Bold · 12 / 12+0.5 (tracking-wider)Secondary/Heavy/BaseBarkAda Bold · 14 / 20<img> todayshape_full today18543:2839Preamble + headline only — no description line. Use when the headline itself is the full message. Name is misleading: the variant actually requires Preamble + Heading + Button, with description hidden.
282 × 16018 2016 (space/space-16)124type=Headline Only → hasDescription=false.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBCarouselItem import com.gcash.designsystem.components.EBCarousel
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
mode: Light Text | Dark Text | appearance: light | dark | .ebAppearance(.light) | appearance=EBCarouselAppearance.Light |
type: Default | with Icon | Headline Only | type: default | withIcon + hasDescription: Bool | leadingIcon: Image?, hasDescription: Bool | leadingIcon: @Composable () -> Unit |
hasPreamble | preamble?: String | preamble: String? | preamble: String? |
hasTextLink | actionLabel?: String | actionLabel: String? | actionLabel: String? |
| (baked raster background) | background: Image | Gradient (slot) | background: AnyView | background: @Composable () -> Unit |
| (drawn grey circle) | part of leadingIcon slot | leadingIcon: Image? | leadingIcon: @Composable (() -> Unit)? |
| (raster chevron) | showChevron: Bool (vector) | showChevron: Bool=true | showChevron: Boolean=true |
| (not modeled) | onTap: () -> Void | onTap: (() -> Void)? | onClick: (() -> Unit)? |
| Carousel - Item | merge into Carousel Item | position is carousel-container-driven | position is carousel-container-driven |
| Carousel Item - Center | |||
| Carousel Item - Side |
ios/Components/Carousel/EBCarousel.swift— containerios/Components/Carousel/EBCarouselItem.swift— itemandroid/components/carousel/EBCarousel.kt— containerandroid/components/carousel/EBCarouselItem.kt— item
// Position-agnostic item — carousel container handles scale/opacity EBCarousel(items: banners) { banner in EBCarouselItem( preamble: banner.preamble, heading: banner.heading, description: banner.description, actionLabel: "Learn more", background: { Image(banner.imageName).resizable().scaledToFill() } ) { onBannerTap(banner) } .ebAppearance(banner.isDarkPhoto ? .light : .dark) } .scrollTransition { content, phase in content .opacity(phase.isIdentity ? 1 : 0.6) .scaleEffect(phase.isIdentity ? 1 : 0.94) }
// Position-agnostic item — HorizontalPager handles scale/opacity HorizontalPager( state = pagerState, pageSpacing = 12.dp, contentPadding = PaddingValues(horizontal = 24.dp) ) { page -> val banner = banners[page] val offset = abs(pagerState.currentPageOffsetFraction) EBCarouselItem( preamble = banner.preamble, heading = banner.heading, description = banner.description, actionLabel = "Learn more", background = { Image(banner.image, contentDescription = null) }, appearance = if (banner.isDarkPhoto) EBCarouselAppearance.Light else EBCarouselAppearance.Dark, onClick = { onBannerTap(banner) }, modifier = Modifier.graphicsLayer { scaleX = lerp(0.94f, 1f, 1f - offset) scaleY = lerp(0.94f, 1f, 1f - offset) alpha = lerp(0.6f, 1f, 1f - offset) } ) }
| Requirement | iOS | Android |
|---|---|---|
| Whole-card tap target | Wrap in Button with combined accessibilityLabel (preamble + heading + description + action). | Modifier.clickable().semantics(mergeDescendants=true). |
| Page-change announcements | Announce current page index via accessibilityValue on the carousel container. | semantics { contentDescription="Banner 2 of 5" } on the pager. |
| Contrast on image | Designer ensures 4.5:1 between text + underlying image area; optional scrim overlay. | Same — ensure WCAG AA against the image region. |
| Reduce motion | Skip scale + opacity transitions when UIAccessibility.isReduceMotionEnabled. | Check Settings.Global.TRANSITION_ANIMATION_SCALE; skip graphicsLayer animation. |
| Min touch target | 282×160 ≫ 44 pt ✓ | 282×160 ≫ 48 dp ✓ |
- Use inside
EBCarousel— never as a standalone banner. - Pick
lightordarkappearance based on the photo's dominant luminance. - Keep the heading to 2 lines max — 282 px is narrow.
- Use Preamble to set category context (e.g. PROMO, NEW, TIP).
- Don't use Carousel Item - Center / Side directly — let the container apply those styles.
- Don't hide the chevron when the card is tappable — it's the affordance.
- Don't stack Preamble + Description + Button + Icon all at once — the content area is 129 px wide.
- Don't put photo-heavy artwork behind small text without a scrim.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Requires Rework | Position-specific duplicates (Item / Center / Side) should be one component with container-driven styling. |
| C2 | Variant & Property Naming | Needs Refinement | Mixed casing; Headline Only is mis-named. |
| C3 | Token Coverage | Ready | Text colors bind to main/carousel/color/*, text/color-text-inverse*, radius + shadow tokens. |
| C4 | Native Mappability | Requires Rework | Background is a baked raster — needs to become a slot. Position-as-variant doesn't map to pager APIs. |
| C5 | Interaction State Coverage | Needs Refinement | Default only — no pressed state for a tappable card. |
| C6 | Asset & Icon Quality | Needs Refinement | Raster chevron; drawn circle icon placeholder. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until consolidation + slots land. |
mode (2) × type (3) × hasTextLink (2) × hasPreamble (2)=24 combinatorial variants, but only 10 are modeled — the author pruned invalid combinations (e.g. Headline Only without Preamble, with Icon with TextLink). Each mode has 5 variants.
| Mode | Type | hasTextLink | hasPreamble | Node | Dimensions |
|---|---|---|---|---|---|
| Light Text | Default | yes | no | 18543:2807 | 282 × 160 |
| Light Text | with Icon | no | no | 18543:2818 | 282 × 160 |
| Light Text | Default | yes | yes | 18543:2826 | 282 × 160 |
| Light Text | Headline Only | yes | yes | 18543:2839 | 282 × 160 |
| Light Text | Default | no | no | 18543:2850 | 282 × 160 |
| Dark Text | Default | yes | no | 18543:2856 | 282 × 160 |
| Dark Text | with Icon | no | no | 18543:2867 | 282 × 160 |
| Dark Text | Default | yes | yes | 18543:2875 | 282 × 160 |
| Dark Text | Headline Only | yes | yes | 18543:2888 | 282 × 160 |
| Dark Text | Default | no | no | 18543:2899 | 282 × 160 |
hasPreamble vs with Icon); Headline Only mis-named. Openmode should be appearance. OpenA selection control for binary and partial choices. 33 variants across isSelected (true/false/indeterminate) × State (Default/Pressed/Focused/Disabled/Error) × Size (Small/Medium/Large). Code Connect registration pending.
Contexts are illustrative. Final screens will reference actual GCash patterns.
Toggle size and state to see the checkbox update in real time.
CheckboxItem compound component provides label + description pairing.icon-check child layer (C6 resolved). All 5 interaction states and indeterminate defined across 3 sizes (C5 resolved). Carries its own visual states and token bindings.main/checkbox/color/...). isSelected uses true/false/indeterminate. All property values follow boolean and enum standards (C2 resolved).CheckboxItem compound component wraps Checkbox + Label + Description for accessible form groups.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Unchecked | Yes | Yes | isSelected=false | Border-only container. 3 sizes. |
| Checked | Yes | Yes | isSelected=true | Blue fill + separable icon-check layer. |
| Indeterminate | Yes | Yes | isSelected=indeterminate | Blue fill + icon-indeterminate dash. |
| Disabled | Yes | Yes | State=Disabled | 40% opacity. Checked: #9BC5FD fill. |
| Pressed | Yes | Yes | State=Pressed | Unchecked: #EBF2FF bg. Checked: #0F57C8. |
| Focused | Yes | Yes | State=Focused | Blue #1972F9 border stroke. |
| Error | Yes | Yes | State=Error | Red border / red #D81E1E fill. |
isSelected=Yes/Norenamed toisSelected=true/falsein Figma — now maps correctly to SwiftBooland KotlinBooleanC2 Fixed- Checkmark rebuilt as a separable
icon-checkchild layer inside each checked container — engineers can now tint, swap, and reference it via Code Connect C6 Fixed - Added 27 new variants — State (Pressed/Focused/Disabled/Error) × isSelected (true/false) × Size, plus
isSelected=indeterminateper size withicon-indeterminatedash layer. 6 → 33 total variants C5 Fixed
- Code Connect mappings not registered. All structural blockers resolved — registration can now proceed against the 33-variant
isSelected × State × Sizeschema. C7 · Code Connect Linkability
CheckboxItemcompound component created. Composes Checkbox + Label (HeyMeow Rnd Bold) + Description (BarkAda Medium). 4 variants:isSelected(true/false) ×Size(Small 14px label / Medium 18px label). Node:17734:161220. Created
33 variants across 3 axes: State (Default/Pressed/Focused/Disabled/Error) × isSelected (true/false/indeterminate) × Size (Small/Medium/Large). All interaction states carry proper container fills, strokes, and separable icon layers.
Empty container with border stroke. Represents a deselected option.
Filled container with white checkmark. Represents a selected option. Checkmark is rendered via a separable icon-check child layer.
All interaction states now have defined colors. Token paths follow main/checkbox/color/{state}/{role} convention.
| State | Role | Token | DEFAULT | PRESSED | DISABLED | ERROR |
|---|---|---|---|---|---|---|
| Unchecked | Border | unselected/border | #D7E0EF | #1972F9 | #D7E0EF | #D81E1E |
| Unchecked | Container bg | unselected/bg | – | #EBF2FF | – | – |
| Checked | Container bg | selected/bg | #1972F9 | #0F57C8 | #9BC5FD | #D81E1E |
| Checked | Checkmark | selected/icon-check | #FFFFFF | #FFFFFF | #FFFFFF | #FFFFFF |
| Indeterminate | Container bg | indeterminate/bg | #1972F9 | – | – | – |
| Indeterminate | Dash icon | indeterminate/icon | #FFFFFF | – | – | – |
| Focused | Border | focused/border | #1972F9 (all isSelected values) | |||
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios" // Or in Package.swift: .package( url: "https://github.com/AY-Org/eb-ds-ios", from: "2.0.0" )
Android — Gradle (Kotlin DSL)
// build.gradle.kts (app) dependencies { implementation("com.eastblue.ds:checkbox:2.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.checkbox.* // Compose
Package not yet published. These are the planned distribution paths. API shape is final — native implementation is pending.
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| isSelected | isOn: Binding<Bool> | checked: Boolean | true/false |
| isSelected=indeterminate | toggleIndeterminate | TriStateCheckbox | Partial selection |
| Size | .controlSize() | size=EBCheckboxSize.* | Small 16px, Medium 20px, Large 24px |
| State=Disabled | .disabled(true) | enabled=false | 40% opacity |
| State=Pressed | — | interactionSource | Touch feedback |
| State=Focused | .focused() | interactionSource | Keyboard/switch nav |
| State=Error | .ebError(true) | isError=true | Form validation |
// Unchecked (default state) EBCheckbox(isOn: $isSelected) .controlSize(.regular) // Small size EBCheckbox(isOn: $isSelected) .controlSize(.mini)
// Unchecked (default state) EBCheckbox( checked = false, onCheckedChange = { isSelected = it }, size = EBCheckboxSize.Medium ) // Small size EBCheckbox( checked = false, onCheckedChange = { isSelected = it }, size = EBCheckboxSize.Small )
// Checked (selected state) EBCheckbox(isOn: .constant(true)) .controlSize(.regular) // Bound to state @State private var isChecked = true EBCheckbox(isOn: $isChecked) .controlSize(.large)
// Checked (selected state) EBCheckbox( checked = true, onCheckedChange = { isSelected = it }, size = EBCheckboxSize.Medium ) // Bound to state var isChecked by remember { mutableStateOf(true) } EBCheckbox( checked = isChecked, onCheckedChange = { isChecked = it } )
// Indeterminate (partial selection) EBCheckbox(isOn: $isSelected) .controlSize(.regular) .toggleIndeterminate(true)
// Indeterminate (partial selection) TriStateCheckbox( state = ToggleableState.Indeterminate, onClick = { /* cycle state */ }, modifier = Modifier.size(EBCheckboxSize.Medium) )
// Disabled unchecked EBCheckbox(isOn: $isSelected) .controlSize(.regular) .disabled(true) // Disabled checked EBCheckbox(isOn: .constant(true)) .controlSize(.regular) .disabled(true)
// Disabled unchecked EBCheckbox( checked = false, onCheckedChange = {}, enabled = false, size = EBCheckboxSize.Medium ) // Disabled checked EBCheckbox( checked = true, onCheckedChange = {}, enabled = false, size = EBCheckboxSize.Medium )
// Error state (form validation) EBCheckbox(isOn: $isSelected) .controlSize(.regular) .ebError(true)
// Error state (form validation) EBCheckbox( checked = false, onCheckedChange = { isSelected = it }, isError = true, size = EBCheckboxSize.Medium )
| Requirement | iOS | Android |
|---|---|---|
| Minimum touch target | 44 x 44 pt | 48 x 48 dp |
| Accessibility label | .accessibilityLabel("Accept terms") | semantics { contentDescription="Accept terms" } |
| Checked state announcement | VoiceOver reads "checked" / "unchecked" automatically via Toggle | TalkBack reads state automatically via Checkbox semantics |
| Indeterminate | toggleIndeterminate reads "mixed" | TriStateCheckbox reads "partially checked" |
Component is icon-only. Wrapping containers must provide an accessible label. Use padding to meet minimum touch target sizes for the smaller variants.
Do
Pair with a visible label adjacent to the checkbox. Checkboxes must always have associated text.
Don't
Use for a single binary toggle — use Switch/Toggle instead. Checkboxes are for multi-select scenarios.
Do
Use for multi-select scenarios — forms, filter lists, settings, and select-all patterns.
Don't
Use a standalone Checkbox without an accessible label. Pair with CheckboxItem or an adjacent text label for form use.
Do
Expand touch area via padding when using Small (16px) size — minimum touch target is 44pt / 48dp.
Don't
Omit an accessible label. Use .accessibilityLabel / contentDescription when no visible label is present.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Root frame named container. Simple, semantic hierarchy. No generic layer names. |
| C2 | Variant & Property Naming | Ready | isSelected now uses true/false — corrected in Figma. Maps directly to Swift Bool / Kotlin Boolean. indeterminate property is a C5 concern (missing state variant). |
| C3 | Token Coverage | Ready | All states have defined color values. Default, Pressed, Disabled, Error, Focused, and Indeterminate containers use distinct fills/strokes. Separable icon-check and icon-indeterminate layers present. |
| C4 | Native Mappability | Ready | Maps to Toggle(.checkbox) / Checkbox. Indeterminate maps to TriStateCheckbox. Label pairing via CheckboxItem compound component. |
| C5 | Interaction State Coverage | Ready | All 5 interaction states defined (Default, Pressed, Focused, Disabled, Error) × isSelected (true/false) × 3 sizes. Indeterminate added as isSelected=indeterminate with icon-indeterminate dash layer. 33 total variants. |
| C6 | Asset & Icon Quality | Ready | Checkmark rebuilt as a separable icon-check child vector layer inside each checked container. Can be tinted via selected/icon-check token and swapped natively. |
| C7 | Code Connect Linkability | Needs Refinement | All structural blockers resolved (C2, C5, C6). Ready for CLI mapping registration. No mappings registered yet. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Ready | isSelected=true/false — maps directly to Swift Bool and Kotlin Boolean |
| Icon/asset quality | Ready | icon-check is now a named, separable child layer — can be mapped to a native icon slot via Code Connect |
| State coverage | Ready | All interaction states defined — Default, Pressed, Focused, Disabled, Error, plus Indeterminate |
| Usage descriptions | Ready | All 33 variants have usage descriptions attached in Figma |
| Native component file | Pending | EBCheckbox.swift / EBCheckbox.kt not yet created |
5 State × 3 isSelected × 3 Size=45 theoretical. isSelected=indeterminate only ships State=Default, so actual count is (5 × 2 × 3) + (1 × 1 × 3)=33 variants.
| isSelected | States | Sizes | Count |
|---|---|---|---|
| false | Default, Pressed, Focused, Disabled, Error | Small, Medium, Large | 15 |
| true | Default, Pressed, Focused, Disabled, Error | Small, Medium, Large | 15 |
| indeterminate | Default only | Small, Medium, Large | 3 |
View full State × isSelected × Size breakdown (33 rows)
| isSelected | State | Size | Node ID |
|---|---|---|---|
| false | Default | Small | 17143:2465 |
| false | Pressed | Small | 17733:968 |
| false | Focused | Small | 17733:971 |
| false | Disabled | Small | 17733:974 |
| false | Error | Small | 17733:977 |
| true | Default | Small | 17143:2468 |
| true | Pressed | Small | 17733:980 |
| true | Focused | Small | 17733:984 |
| true | Disabled | Small | 17733:988 |
| true | Error | Small | 17733:992 |
| false | Default | Medium | 17143:2471 |
| false | Pressed | Medium | 17733:996 |
| false | Focused | Medium | 17733:998 |
| false | Disabled | Medium | 17733:1000 |
| false | Error | Medium | 17733:1002 |
| true | Default | Medium | 17143:2473 |
| true | Pressed | Medium | 17733:1004 |
| true | Focused | Medium | 17733:1008 |
| true | Disabled | Medium | 17733:1012 |
| true | Error | Medium | 17733:1016 |
| false | Default | Large | 17143:2476 |
| false | Pressed | Large | 17733:1020 |
| false | Focused | Large | 17733:1022 |
| false | Disabled | Large | 17733:1024 |
| false | Error | Large | 17733:1026 |
| true | Default | Large | 17143:2478 |
| true | Pressed | Large | 17733:1028 |
| true | Focused | Large | 17733:1032 |
| true | Disabled | Large | 17733:1036 |
| true | Error | Large | 17733:1040 |
| indeterminate | Default | Small | 17733:1044 |
| indeterminate | Default | Medium | 17733:1048 |
| indeterminate | Default | Large | 17733:1052 |
HeyMeow Rnd Bold, 14px/18px) + Description (BarkAda Medium, 12px). Wraps atomic Checkbox with label pairing for accessible form use. CreatedisSelected=X, Size=Y, State=Z to State=X, isSelected=Y, Size=Z. State is now the first property axis in the component set. Section renamed from "Claude Testing" to "Checkbox". Updated#0F57C8 (checked); Focused uses blue border; Disabled uses 40% opacity; Error uses red border / #D81E1E fill. FixedisSelected=indeterminate variants for Small, Medium, and Large. Each contains a blue container frame with a named icon-indeterminate horizontal dash child layer. Maps to ToggleableState.Indeterminate (Android) and toggleIndeterminate (iOS). FixedisSelected=true variants. Created clean blue container frames (4px radius) with a named icon-check child vector inside each. Engineers can now tint via selected/icon-check token and map to a native icon slot. Nodes: 17721:962 (Small), 17721:963 (Medium), 17721:964 (Large). FixedisSelected=Yes/No to isSelected=true/false. Now maps directly to Swift Bool and Kotlin Boolean for Code Connect. C2 criterion resolved. FixedisChecked. Figma metadata confirms the property is isSelected. All documentation updated. FixedisSelected=Yes/No instead of true/false. Incompatible with Swift Bool and Kotlin Boolean for Code Connect mapping. Fixed in v1.2.0A small pill-shaped component used for filters, tags, selected values, and pill-styled dropdown triggers. Currently split across two Figma components — Filter (6 variants) and Filter with Dropdown (2 variants) — that share the same pill anatomy and should be consolidated into a single Chip component with semantic slot props.
style (filled/light/outline), leading (none/avatar/icon), and trailing (none/close/chevron) slot props. Fixes the paradigm mismatch, collapses 8 variants into cleaner prop combinations, and aligns with native chip APIs.Contexts are illustrative. Final screens will reference actual GCash patterns. Chips appear in filter rows below title bars, as applied-filter readouts on list screens, and as pill-styled dropdown triggers for sort/filter controls.
Toggle style, leading, and trailing to see every combination.
type (primary/light/outline) + with icon (yes/no), "Filter with Dropdown" uses type (default / "with active time"). Different property schemas, yes/no booleans, and inconsistent value naming. C2icon-placeholder) not a real Avatar instance or swappable Icon. Close icon is a vector. Breaks compositional inheritance — changes to Avatar won't propagate. C6| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Active / Selected | Yes | Yes | Filter · type=primary | Filled blue background, white label. Used when a filter is applied. |
| Inactive (light) | Yes | Yes | Filter · type=light | Light gray pill, gray label. Used for unapplied filters or tag readouts. |
| Inactive (outline) | Yes | Yes | Filter · type=outline | White pill, gray border, gray label. Alternative inactive style. |
| Dropdown trigger | Yes | Yes | Filter w/ Dropdown · type=default | Light style with chevron. Used for sort/filter pickers. |
| Dropdown with value | Yes | Yes | Filter w/ Dropdown · type=with active time | Dropdown trigger displaying the selected value in blue (label-link). |
| Pressed / Disabled / Error | N/A | N/A | — | Not defined in Figma. Engineers must improvise. C5 |
- Two components share the same pill anatomy.Filter (6 variants) and Filter with Dropdown (2 variants) should consolidate into a single Chip with
style,leading, andtrailingslot props. C2 · Variant & Property Naming - Boolean values use
yes/noinstead oftrue/false. Thewith iconproperty is incompatible with SwiftBool/ KotlinBooleanfor Code Connect. C2 · Variant & Property Naming - Enum value
"with active time"contains spaces and unclear naming. Should bewithValue/hasSelectedValue, or collapsed into an optionalselectedValue: Stringprop. C2 · Variant & Property Naming - Leading slot is a hardcoded placeholder circle. The 24px gray
icon-placeholderblocks instance swap — should be a swappable Avatar or Icon. C6 · Asset & Icon Quality - No pressed / selected / disabled / error states. State coverage missing — chips are interactive (tap-to-apply, tap-to-remove) and need full state documentation. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked until the two-component consolidation and prop rename land. C7 · Code Connect Linkability
- Rename "Filter" → "Chip" and merge with "Filter with Dropdown" into one component. Matches industry terminology (Material FilterChip/InputChip, Polaris Tag, Carbon Tag) and lets a single component cover filters, tags, and dropdown triggers. Property
- Replace
type+with icon+ (dropdown)typewith three semantic slot props:
•style=filled/light/outline
•leading=none/avatar/icon
•trailing=none/close/chevron
Plus an optionalselectedValuestring for the "Sort by X" pattern. Property - Replace the 24px placeholder circle with a real slot — instance-swap an Avatar (for person filters) or Icon (for category filters). Follows the same pattern Avatar Group uses for its inner Avatar composition. Slot
- Add pressed / disabled states so applied-filter chips have a documented pressed affordance (important because they're tappable-to-remove) and disabled filters have a defined appearance. State
Three base styles shared across filter and dropdown use cases. Height is 32px across all variants; padding and gap vary with the leading/trailing slots.
Brand blue fill with white label. Represents an active/applied filter. Shown with leading avatar + trailing close.
Light gray fill with gray label. Used for inactive filters, tags, or as the base style for dropdown triggers.
White fill with 2px gray border and gray label. Alternative inactive style for surfaces where the light gray pill wouldn't contrast enough.
Light style with a trailing chevron. Used as a pill-styled dropdown trigger. The "with active time" variant shows a selected value in blue (label-link) — collapsed under the proposed selectedValue optional prop.
Three style modes, each with bg, label, and icon tokens. Dropdown adds label-link and chevron.
| Style | Role | Token | Value |
|---|---|---|---|
| Filled | bg | main/filter/color/primary/bg | #005CE5 |
| — | label | main/filter/color/primary/label | #FFFFFF |
| — | icon | main/filter/color/primary/icon | #F6F9FDB8 |
| Light | bg | main/filter/color/secondary/bg | #EEF2F9 |
| — | label | main/filter/color/secondary/label | #6780A9 |
| — | icon | main/filter/color/secondary/icon | #7E96BE |
| — | selected value | main/filter/color/secondary/label-link | #005CE5 |
| — | chevron | main/filter/color/secondary/chevron | #005CE5 |
| Outline | border | main/filter/color/tertiary/border | #D7E0EF |
| — | label | main/filter/color/tertiary/label | #6780A9 |
| — | icon | main/filter/color/tertiary/icon | #7E96BE |
| Property | Token | Value |
|---|---|---|
| Height | — | 32px |
| Corner radius | radius/radius-pill | 99px |
| Padding (with leading) | — | 4L / 14R |
| Padding (no leading) | — | 14 horizontal |
| Padding (dropdown) | space/space-16 · space/space-12 | 16L / 12R |
| Outline border width | — | 2px |
| Leading avatar size | — | 24 × 24 (should be Avatar instance) |
| Close icon size | — | 16 × 16 |
| Chevron size | — | 24 × 24 |
| Gap: icon → label | space/space-4 | 4px |
| Gap: label → close | space/space-8 | 8px |
| Gap: label → selected value (dropdown) | space/space-8 | 8px |
| Property | Value |
|---|---|
| DS text style | Primary/Label/Base |
| Font | HeyMeow Rnd |
| Weight | 700 (Bold) |
| Size | 16px |
| Line height | 16px |
| Tracking | +0.25 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:chip:1.0.0") }
| Proposed Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| style | .ebStyle(.filled / .light / .outline) | style=EBChipStyle.Filled / Light / Outline | Replaces Filter's type property |
| leading | leading: EBChipLeading? | leading: @Composable (() -> Unit)? | .none / .avatar(...) / .icon(...) |
| trailing | trailing: EBChipTrailing? | trailing: EBChipTrailing? | .none / .close / .chevron |
| selectedValue | selectedValue: String? | selectedValue: String? | Optional selected-value text (for dropdown "Sort by X" pattern) |
| label | title: String | label: String | Main label text |
| onTap / onClose | action / onRemove | onClick / onRemove | Two callbacks for applied-filter chips (tap body vs. tap close) |
// Applied filter — brand fill, avatar + close EBChip("Filter Name", leading: .avatar(EBAvatar(initials: "DM")), trailing: .close, onRemove: { /* remove filter */ }) .ebStyle(.filled) // Inactive filter — light, label only EBChip("Category") .ebStyle(.light) // Dropdown trigger with selected value EBChip("Sort by", selectedValue: "Conservative first", trailing: .chevron, action: { /* open dropdown */ }) .ebStyle(.light)
// Applied filter — brand fill, avatar + close EBChip( label = "Filter Name", style = EBChipStyle.Filled, leading = { EBAvatar(initials = "DM") }, trailing = EBChipTrailing.Close, onRemove = { /* remove filter */ } ) // Inactive filter — light, label only EBChip( label = "Category", style = EBChipStyle.Light ) // Dropdown trigger with selected value EBChip( label = "Sort by", selectedValue = "Conservative first", style = EBChipStyle.Light, trailing = EBChipTrailing.Chevron, onClick = { /* open dropdown */ } )
| Requirement | iOS | Android |
|---|---|---|
| Tap target | 32px height is below HIG 44pt — wrap in a 44pt-tall hit area | 32dp height is below Material 48dp — expand touch target via Modifier.minimumInteractiveComponentSize() |
| Role | .accessibilityAddTraits(.isButton) | Use semantics { role=Role.Button } |
| Close button label | .accessibilityLabel("Remove filter: \(label)") | contentDescription="Remove filter: $label" |
| Dropdown indicator | .accessibilityHint("Double-tap to change") | Announce chevron via role + state |
| Selected state | .accessibilityAddTraits(.isSelected) for style=filled | selected=true in semantics for active filters |
Do
Use style=filled for applied/active filters, style=light for inactive filters and dropdown triggers, style=outline when the surface beneath is already light gray.
Don't
Mix filled and light chips in the same filter row without intent — it signals "one filter is selected," so using both styles for unrelated reasons misleads the user.
Do
Pair the close affordance with applied filters so users can remove them with a single tap.
Don't
Add a close icon to dropdown-trigger chips — the chevron already indicates the tap behavior; a close icon implies removal instead of picking.
Do
Keep the chip label to one or two words. Use selectedValue to show the picked option separately.
Don't
Stretch chips to fill width — they're meant to fit their content. If a control needs full width, use a Button or Select Field instead.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic names: container, Placeholder, Close, Chevron Down. |
| C2 | Variant & Property Naming | Needs Fix | Split across two components with mismatched property schemas. with icon uses yes/no. Dropdown uses type="with active time". Rename to Chip and use style / leading / trailing. |
| C3 | Token Coverage | Ready | All colors, radii, spacing, and typography bound to tokens. |
| C4 | Native Mappability | Ready | Maps to custom pill on iOS and FilterChip/InputChip on Android. Single EBChip composable works once consolidated. |
| C5 | Interaction State Coverage | Needs Fix | No pressed / disabled / error states. Selected state is implied by style=filled but not defined as a separate variant. |
| C6 | Asset & Icon Quality | Needs Fix | Leading slot is a hardcoded 24px gray circle (icon-placeholder) — should be a swappable Avatar/Icon instance. |
| C7 | Code Connect Linkability | Pending | Blocked by C2 consolidation. Clean prop names land once Chip is renamed and merged. |
| Source | Style | Leading | Trailing | Node ID |
|---|---|---|---|---|
| Filter | Filled (primary) | Avatar | Close | 18336:22244 |
| Filter | Filled (primary) | — | — | 18336:22253 |
| Filter | Light | Avatar | Close | 18336:22257 |
| Filter | Light | — | — | 18336:22266 |
| Filter | Outline | Avatar | Close | 18336:22270 |
| Filter | Outline | — | — | 18336:22279 |
| Filter w/ Dropdown | Light | — | Chevron | 18336:22292 |
| Filter w/ Dropdown | Light (w/ selected value) | — | Chevron | 18336:22284 |
After rename + consolidation, these 8 variants collapse into 3 style × 3 leading × 3 trailing=27 prop combinations on one Chip component (most unused; actual DS palette is ~8 common combinations).
type + with icon. Dropdown uses type="with active time". Booleans are yes/no. Should consolidate to style / leading / trailing + optional selectedValue. OpenA small numeric pill used as trailing content in list rows, tab items, and section headers. Two formats: single integer for counts (notifications, unread, pending items) and N / M slash for progress-against-capacity (slots used, steps completed). Fixed 24 px tall, hugs digit width. 4 variants across state (empty / filled) × with limit (yes / no).
with limit → hasLimit with true/false; parameterize count: Int and limit: Int?; add 99+ overflow handling. Variant count stays at 4.Counter appears inline with text to show counts — section headers for unread notifications, tab item badges for pending items, limit/slot usage displays.
Flip with limit to switch between the two formats. Type count (and limit when applicable) to see the pill hug digit width. Try 247 with maxDisplay=99 to see the overflow collapse to 99+. state auto-derives from count: 0 is muted empty, anything else is brand-blue filled.
with limit uses yes/no strings instead of true/false. Count/limit values are hardcoded text — not usable for real counts without detaching.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Empty | Yes | Yes | state=empty | Count is 0. Muted label, same bg. Used to indicate "nothing pending". |
| Filled | Yes | Yes | state=filled | Count is greater than 0. Brand-blue label, same bg. Used when there's activity to surface. |
| With limit | Yes | Yes | with limit=yes | Renders "N / M" (e.g. "3 / 10") — for slot/limit displays like "beneficiaries used". |
| Without limit | Yes | Yes | with limit=no | Renders a single integer. Used for unread counts, inbox badges. |
| Pressed / Disabled | N/A | N/A | — | Counter is display-only — no interactive states. |
| Overflow (99+) | Missing | Missing | Not modeled | Real counts can exceed 99 (unread messages, notifications). Need overflow display ("99+") — not built today. |
with limitusesyes/nostrings. Should behasLimit: true/falsefor direct SwiftBool/ KotlinBooleanmapping. C2 · Variant & Property Naming- Count and limit values are hardcoded text. "0 / 10" and "10 / 10" are baked into each variant — consumers must detach to show any other value. Expose
count: Intandlimit: Int?as parameters so the component renders its own formatted string. C2 · Variant & Property Naming - No overflow handling for large counts. Inbox counts routinely exceed 99 (unread messages, notifications). The single-integer format needs "99+" display; the slash format needs equivalent overflow when count exceeds the limit. Not modeled today. C5 · Interaction State Coverage
- Code Connect mappings not registered. Trivial once parameterization and boolean rename land. C7 · Code Connect Linkability
- Rename
with limittohasLimit. Change values fromyes/nostrings totrue/false. Aligns with the boolean naming convention used across the DS. Rename - Expose
countandlimitas parameters.count: Int(required) +limit: Int?(optional — activates slash format when set). Drop the text overrides; the component formats the string itself. Eliminates the "detach to change the number" anti-pattern. Property - Derive
statefromcount. Empty whencount==0, filled otherwise. Removes one property the consumer has to set manually. Allow explicit override for edge cases. Property - Add overflow handling for both formats. Single-integer:
count > maxDisplayrenders "99+". Slash format:count > limitshould clamp display or render "limit+" to prevent visual overflow. Pattern used in Material / Apple badges. State - Document the two use cases. Single-integer=how many of X are there (notifications, unread, pending). Slash format=progress against capacity (slots used, steps completed). Clarify in the spec so teams pick the right format. Docs
- Document Counter ↔ Badge relationship. Counter=numeric (count, progress). Badge=status/tag label (Success, Premium). Teams reach for the wrong one without this guidance. Docs
18482:71322Slash format showing zero progress against a limit ("0 / 10"). Muted label on neutral bg. Used when no slots are filled yet.
emptyyes0 / 10| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface | empty/bg | #EEF2F9 |
| Label | empty/label | #C2CFE5 |
240 × 8 (hug width)99 (pill)53 (for "0 / 10")Primary/Label/SmallHeyMeow Rnd Bold14 / 14+0.25center18482:71324Slash format with a filled count ("10 / 10"). Brand-blue label on neutral bg. Used when capacity is at or approaching the limit.
filledyes10 / 10| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface | filled/bg | #EEF2F9 |
| Label | filled/label | #072592 |
18482:71326, 18482:71328Standalone count — notifications, unread messages, pending items. Hugs tightly around the digit (24 × 24 for single digit, grows for 2+ digits). Empty state shown muted; filled state shown in brand-blue. Pairs with overflow handling ("99+") once count is parameterized.
noempty | filled024 (circle for single digit)hug (grows with digit count)0 × 8.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBCounter
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
| (hardcoded text "0 / 10", "10 / 10") | count: Int | count: Int | count: Int |
with limit: yes | no | limit: Int? (nil=single-integer format; set=slash format) | limit: Int? | limit: Int? |
state: empty | filled | derived from count (0=empty, >0=filled) | auto, with override | auto, with override |
| (not modeled) | maxDisplay: Int=99 | maxDisplay: Int=99 | maxDisplay: Int=99 |
ios/Components/Counter/EBCounter.swiftandroid/components/counter/EBCounter.kt
// Single integer — notification count EBCounter(count: unreadCount) // Overflow — renders "99+" when count > 99 EBCounter(count: 247) // Slash format — progress against capacity EBCounter(count: beneficiaries.count, limit: 10) // "3 / 10" // Trailing counter inside a list row EBListRow( leading: EBAvatar(initials: "JD"), label: "Messages", trailing: .counter(unreadCount) )
// Single integer — notification count EBCounter(count = unreadCount) // Overflow — renders "99+" when count > 99 EBCounter(count = 247) // Slash format — progress against capacity EBCounter(count = beneficiaries.size, limit = 10) // "3 / 10" // Trailing counter inside a list row EBListRow( leading = { EBAvatar(initials = "JD") }, label = "Messages", trailing = { EBCounter(count = unreadCount) } )
| Requirement | iOS | Android |
|---|---|---|
| Context-aware label | Set .accessibilityLabel("5 unread messages") — screen readers should hear what the number means, not just the digits. | Set contentDescription="5 unread messages". |
| Zero state | When count==0, default behavior (hideWhenZero: true) removes the pill from the accessibility tree entirely. Best practice — nothing to announce. | Same — hidden at zero by default. |
| Overflow | Announce the actual count, not "99+" — e.g. "247 unread". The "99+" is a visual truncation, not the truth. | Same — screen reader gets the real number. |
| Contrast | Filled: #072592 on #EEF2F9=11.8:1 ✓. Empty: #C2CFE5 on #EEF2F9=1.4:1 — fails AA. Empty is decorative (shown only when the user opts out of hideWhenZero); don't use for counts that must be read. | Same ratios apply. |
- Use for numeric counts — unread messages, pending items, slots used.
- Pair with a screen-reader-friendly label ("3 of 10 beneficiaries", not "3 / 10").
- Hide the counter when
count==0if the zero state is the norm (prevents muted pills cluttering the UI). - Prefer Counter + Section Header / Tab Item compositions over standalone use.
- Don't use Counter for status labels ("New", "Draft") — use Badge.
- Don't put non-numeric text inside — this isn't a generic chip.
- Don't attach onClick / onPress — Counter is a display primitive. Wrap the parent in an interactive container if needed.
- Don't hardcode the count/limit text by detaching — use the (proposed)
count/limitprops.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Clean one-layer structure (container + label). |
| C2 | Variant & Property Naming | Rework | with limit → hasLimit (bool). Parameterize count/limit. |
| C3 | Token Coverage | Ready | Surface + label bound to main/counter/color/*. |
| C4 | Native Mappability | Ready | Maps to a tiny EBCounter view/composable — Text inside a Capsule. |
| C5 | Interaction State Coverage | Needs Refinement | Display-only — no interactive states needed. But overflow ("99+") for large counts is missing. |
| C6 | Asset & Icon Quality | N/A | No assets. |
| C7 | Code Connect Linkability | Not Mapped | Trivial once parameterization + boolean rename land. |
state (2) × with limit (2)=4 variants. Both formats are kept — they solve different problems: single-integer for counts, slash for progress.
| # | Node | state | with limit | Format | Example | Dimensions |
|---|---|---|---|---|---|---|
| 1 | 18482:71322 | empty | yes | slash | 0 / 10 | 53 × 24 |
| 2 | 18482:71324 | filled | yes | slash | 10 / 10 | 59 × 24 |
| 3 | 18482:71326 | empty | no | single integer | 0 | 25 × 24 |
| 4 | 18482:71328 | filled | no | single integer | 0 | 24 × 24 |
with limit → hasLimit, parameterize count + limit, add 99+ overflow. Variant count stays at 4. Openwith limit: yes/no → hasLimit: true/false. Direct Swift Bool / Kotlin Boolean mapping. Opencount: Int + limit: Int?; drop hardcoded text. Derive state from count. OpenmaxDisplay (default 99); counts beyond render "99+" in single-integer format, and clamp in slash format. OpenThe popover/panel surface that appears when the Date Picker trigger is tapped. 3 variants on a single Type axis — Date (a 6-row × 7-col calendar grid of day cells), Year (a 3-col scrollable grid of year cells), Month (a 3-col grid of month cells). All variants share a 360×296 frame, header with Prev/Next chevrons, white bg, 1px border, 12px-blur drop shadow. Sibling to the Date Picker trigger.
DatePicker(.graphical)) and Material 3 (DatePicker/DatePickerDialog) render the calendar surface with full locale, keyboard, and a11y support built in. The DS should ship a tokenized wrapper, not a from-scratch Figma redraw. Current component has raster chevrons (C6), no cell state coverage (C5), day-of-week layer names (C1), misleading Type axis (C2), a fake drawn scrollbar (C4), and asymmetric Month navigation (C4).The group appears immediately below the Date Picker trigger when it enters State=Active. The three Type variants swap when the user taps the header (Date → Year → Month).
Switch Type to compare the Date grid, Year grid, and Month grid. All three share the same 360×296 card frame and header structure.
Scrollbar rectangle instead of using a scrollable container.Type=Date | Year | Month is misleading — "Date" means day-grid view. Native convention is mode: day | month | year. Month variant has only a Next chevron (no Prev), asymmetric from Date and Year. Day cells use day-of-week layer names (Sunday...Saturday) as semantic roles.DatePicker and Compose DatePickerDialog render their own calendar surface internally — this Figma component has no 1:1 native primitive. It also is currently nested inline inside the trigger (see Date Picker), not exposed as a standalone overlay.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Day grid | Yes | Yes | Type=Date | 6 rows × 7 cols. Weekday header (Su/M/T/W/Th/F/Sa). Today shown with 1.5px blue ring. Prev/next month days dimmed. |
| Year grid | Yes | Yes | Type=Year | 3-col grid with overflow-clip and a drawn "Scrollbar" decoration. Selected year shown with 1px blue ring + blue label. |
| Month grid | Yes | Yes | Type=Month | 3-col grid with all 12 months. Missing Prev chevron — only Next is drawn. Selected month shown with 1px blue ring. |
| Cell: Today / Selected | Yes | Yes | Date Picker - Item state | 1.5px blue ring on day cells; 1px blue ring on year/month cells. Native pickers fill the cell with tint instead of ringing it. |
| Cell: Pressed / Hover / Focus | No | No | — | Missing. Native pickers provide these automatically; a DS wrapper only needs to tint them. |
| In-range / Range start / Range end | No | No | — | Not defined. Both platforms support date-range selection; the DS has no tokens or visuals for it. |
| Disabled cell | No | No | — | No business-rule disabled state (e.g. minDate/maxDate). Only the "prev/next month" greyed variant exists. |
| Keyboard navigation / focus ring | No | No | — | No focus styling. Native pickers handle keyboard+TalkBack/VoiceOver by default; the DS needs to preserve that. |
- Day cells are named by weekday, not by index or role. The day rows contain layers literally named
Sunday,Monday...Saturdayinstead ofday-cell[0]...day-cell[6]or a single repeated instance. Weekday is runtime data, not a layer identity. This leaks through to Dev Mode handoff as nonsense layer names. C1 · Layer Structure & Naming - Weekday header rows are instance-swapped
Date Picker - Item, not a dedicated weekday cell. The header row uses the same Date Picker - Item component as the day grid but with a.base/date-iteminner naming. Handoff can't distinguish "day label" from "weekday label" from property alone. C1 · Layer Structure & Naming Type=Date | Year | Monthis misleading. "Date" means the day grid, not a date value. Native convention and consumer mental model ismode: day | month | year. Rename the axis so code-connect params readmode=.dayinstead oftype="Date". C2 · Variant & Property Naming- No 1:1 native primitive for the whole calendar surface. SwiftUI
DatePicker(selection:).datePickerStyle(.graphical)and Material 3DatePicker(state:)both render the full calendar internally. Keeping this as a drawn Figma component means the developer either ignores it (using native) or redraws it from scratch (defeating the purpose of a DS). C4 · Native Mappability - Year variant has a drawn fake
Scrollbarrectangle. Node18414:6277is aScrollbarlayer with a 4px × 80px blue-tinted pill absolutely positioned over the grid. Scroll state can't be represented in static geometry — native pickers render the scroll indicator automatically. Remove, and use a scrollable container instead. C4 · Native Mappability - Month variant is missing the Prev chevron. Node
18431:2826's header has only a Chevron Right — the left slot is an empty 24×24 box (Chevron Leftremoved). Date and Year variants both have bidirectional navigation; Month is asymmetric. C4 · Native Mappability - Cell interaction states not defined on the grid. Only Default, Today (day), Selected (year/month), and Prev/Next (day-only, disabled-looking) exist. Missing: Pressed, Hover/Focus, In-range, Range-start, Range-end, Business-rule disabled (outside minDate/maxDate). C5 · Interaction State Coverage
- Chevrons are raster
shape_fullassets. Both Prev and Next chevrons in every variant referencehttps://www.figma.com/api/mcp/asset/...image URLs rather than a vector icon instance. Won't scale, won't inherit token colors, can't be swapped. Reuse the DS chevron icon component. C6 · Asset & Icon Quality - No dedicated picker surface token scope. The panel bg and border reuse
main/date-picker/month-header/color/bg/...borderas a stand-in — but the month-header is a sub-region, not the surface. Without a distinctmain/date-picker/group/*scope, changing one visually mutates the other. C6 · Asset & Icon Quality - Code Connect mappings not registered. Blocked by C1/C2/C4/C5/C6 and by the recommendation to wrap native pickers instead of mapping this drawn surface directly. C7 · Code Connect Linkability
- Composition — Wrap the native date pickers, don't redraw them. iOS:
DatePicker(selection:, displayedComponents:).datePickerStyle(.graphical)renders the calendar surface with full locale, leap-year, keyboard, VoiceOver, and Dynamic Type support. Material 3:DatePicker(state:)+DatePickerDialogdoes the same. The DS should shipEBDatePickerPanelas a token-styled overlay of the native picker — not a from-scratch Figma surface. Documentation lives as a reference spec here; production code calls the native API. Composition - Family — Unify Date Picker - Item and Month/Year Picker - Item into one
Picker Cell. Day, month, and year cells inside this Group are all selectable cells with the same state semantics. Replace the two sibling components with onePicker Cellwithkind: day | month | yearandstate: default | today | selected | in-range | disabled. Collapses 10 variants across 2 components into one component with two clean axes. Family - Property — Rename
Typetomodeand use native terminology. ChangeType=Date | Year | Monthtomode=day | month | year. Matches SwiftUIdisplayedComponentsand ComposeDisplayMode. Eliminates the ambiguity of "Date" meaning "day grid". Rename - Rename day cells by index/role, not by weekday. Replace the
Sunday...Saturdaylayer names with a single repeatedday-cellinstance indexed by position, or with semantic roles (header-cell,day-cell,spacer-cell). Weekday is data, not layer identity. Rename - Replace chevron raster glyphs with vector icon instances. Swap the two
shape_fullimage references per variant (×3=6 assets) for the DS chevron icon component. Color-bind tomain/date-picker/month-header/color/iconso hover/disabled states propagate. Asset - Add Prev chevron to the Month variant. Navigate between year clusters (2015-2024, 2025-2034...) so Month behaves symmetrically with Date and Year. State
- Remove the drawn Scrollbar on the Year variant. Delete node
18414:6277and its child rectangle. Mark the Grid frame as scrollable in the component spec (overflow-clip already applied) and document that native pickers render the scroll indicator automatically. Property - Add cell state coverage via Picker Cell axes. Expose Pressed, Hover/Focus, In-range, Range-start, Range-end, and business-rule Disabled on the unified Picker Cell component. Link to Figma tokens in a dedicated
main/date-picker/cell/*scope. State - Introduce a
main/date-picker/group/*token scope for the surface. Separate the panel-level bg/border/shadow tokens from the month-header tokens so the two can be themed independently. Token - Document as a reference spec, not a production component. Given the native-pickers-own-this direction, this Figma component should be annotated as a visual reference for designers reviewing what the native picker will look like in GCash theme — not as a component developers are expected to rebuild. Add a description banner in Figma and mark it
_reference. Docs
3 variants on a single Type axis. All share a 360×296 card frame, identical header layout, and the same bg/border/shadow tokens. Only the grid body changes.
Day grid. Header shows "Month / Year" with Prev/Next chevrons. Weekday row (Su/M/T/W/Th/F/Sa) followed by 6 rows of 7 day cells. Today shown with 1.5px blue ring; prev/next-month days dimmed.
Year grid. Header shows "Year". 3-col grid with overflow-clip and a drawn Scrollbar decoration at the right. Selected year shown with 1px blue ring and blue label.
Month grid. Header shows "Year" with only a Next chevron (Prev is missing — asymmetric with Date and Year). 3-col × 4-row grid of 12 months. Selected month shown with 1px blue ring.
Panel-level colors are shared across all three variants. Cell-level colors belong to Date Picker - Item and Month/Year Picker - Item — shown here for cross-reference.
| Role | Token | DEFAULT | SELECTED / TODAY | DISABLED |
|---|---|---|---|---|
| Panel bg | main/date-picker/month-header/color/bg | #FFFFFF | – | – |
| Panel border | main/date-picker/month-header/color/border | #E5EBF4 (1px) | – | – |
| Panel shadow | elevation/app/shadow | 0 6px 12px -8 rgba(2,14,34,.16) | – | – |
| Header label | main/date-picker/month-header/color/label | #0A2757 | – | – |
| Header chevron | main/date-picker/month-header/color/icon | #005CE5 | – | – |
| Weekday bg | main/date-picker/week-header/color/bg | #FFFFFF | – | – |
| Weekday label | main/date-picker/week-header/color/label | #0A2757 | – | – |
| Day cell bg | main/date-picker/day/color/unselected/bg | #FFFFFF | #FFFFFF (ring) | #FFFFFF |
| Day cell label | main/date-picker/day/color/unselected/label | #0A2757 | #005CE5 | #C2CFE5 |
| Today ring (day) | border/color-border-primary | – | #005CE5 (1.5px) | – |
| Selected ring (month/year) | border/color-border-primary | – | #005CE5 (1px) | – |
| Prev/next-month day label | text/color-text-disabled | – | – | #C2CFE5 |
| Scrollbar overlay (Year) | bg/color-bg-inverse (10% opacity) | #0A2757 @ 10% | – | – |
| Property | Value |
|---|---|
| Panel width | 360px |
| Panel height | 296px (fixed on Year/Month; Hug on Date) |
| Panel padding | 16px all sides (space/space-16) |
| Panel corner radius | 0 top-left/top-right, 8px bottom-left/bottom-right |
| Row gap | 8px (space/space-8) |
| Header height | 24px (chevron size sets it) |
| Chevron size | 24 × 24 |
| Day cell size | 32 × 32 (pill radius 30px) |
| Day cell padding | 10px top, 12px bottom, 6px horizontal |
| Month/Year cell | flex-1 × 32, 8px radius (radius/radius-3) |
| Month/Year cell padding | 10px top, 8px bottom, 12px horizontal |
| Month/Year grid gap | 16px horizontal |
| Scrollbar overlay (Year) | 4 × 80, 99px pill, 10% opacity |
| Layer | Text Style | Font | Size | Tracking | Line-height |
|---|---|---|---|---|---|
| Header label (Month/Year, Year, Year) | Primary/Label/Large | Proxima Soft Bold | 18px | 0.25px | 18px |
| Weekday label | Primary/Label/Small | Proxima Soft Bold | 14px | 0.25px | 14px |
| Day cell label | Primary/Label/Light/Small | Proxima Soft Semibold | 14px | 0.25px | 14px |
| Month / Year cell label | Primary/Label/Light/Small | Proxima Soft Semibold | 14px | 0.25px | 14px |
The calendar panel is rendered natively on both platforms — SwiftUI DatePicker with .graphical style, and Material 3 DatePicker / DatePickerDialog. A thin EBDatePickerPanel wrapper can tokenize colors to match the DS without redrawing the surface.
iOS — SwiftUI
DatePicker( "Date of Birth", selection: $birthDate, displayedComponents: .date ) .datePickerStyle(.graphical) .tint(Color.ebBrand) // SwiftUI renders the calendar, year picker, month picker, // keyboard navigation, and VoiceOver support.
Android — Jetpack Compose (Material 3)
val state = rememberDatePickerState() DatePicker( state = state, colors = DatePickerDefaults.colors( selectedDayContainerColor = EBColors.brand, todayDateBorderColor = EBColors.brand ) ) // Material 3 renders the full calendar + day/month/year pickers.
If the DS wants a custom overlay (e.g. custom shadow or brand radius), EBDatePickerPanel can wrap the native component. The Figma spec here is the visual target for the tokenized wrapper — not a layout to rebuild from scratch.
| Figma Property | SwiftUI Equivalent | Compose Equivalent | Notes |
|---|---|---|---|
| Type=Date | displayedComponents: .date (graphical style, default view) | DisplayMode.Picker | Day grid. Native primary view for DatePicker. |
| Type=Year | (tap header label in .graphical style) | DisplayMode.Input / year header tap | Year list surfaced via native interaction, not a separate view. |
| Type=Month | (tap month header in .graphical style) | (year-cluster header tap) | Month grid surfaced via native interaction. Currently missing Prev chevron in Figma. |
| Header label "Month / Year" | header automatically rendered | headline slot | Native pickers render this from selection. No explicit prop. |
| Prev / Next chevrons | automatic on .graphical | automatic on DatePicker | Handled by the native primitive. Raster glyphs in Figma should be removed (C6). |
| Day cell | cell styling via tint / accentColor | colors.dayContentColor, selectedDayContainerColor | Native draws the cell; DS supplies the tint + today ring color. |
| Year / Month cell | styled via tint | colors.yearContentColor, selectedYearContainerColor | Unified into Picker Cell in the family recommendation. |
| Scrollbar overlay (Year) | — | — | Native indicator appears during scroll. Figma decoration should be removed. |
| Requirement | iOS | Android |
|---|---|---|
| Calendar role / traits | Automatic via DatePicker (.isDatePicker trait) | Automatic via Material 3 DatePicker (Role.DatePicker) |
| Keyboard navigation | Arrow keys move between day cells on iPad / hw keyboard | D-Pad + hw keyboard navigation by default |
| Focus ring | System focus ring on focused cell | System focus indicator on focused cell |
| Screen reader label | VoiceOver announces day-of-week, date, month, and Selected / Today | TalkBack announces full date + state |
| Dynamic Type / font scaling | Automatic | Automatic |
| Locale / first-day-of-week | Calendar.current.firstWeekday | Locale.getDefault().firstDayOfWeek |
| minDate / maxDate | in: Date...Date range parameter | selectableDates / yearRange |
Do
Render the panel through SwiftUI's DatePicker(.graphical) or Compose's Material 3 DatePicker. Apply DS tokens via .tint / colors=....
Don't
Don't hand-draw the calendar grid, chevrons, or month/year pickers. You'll reimplement leap-year logic, locale handling, VoiceOver/TalkBack, and keyboard support that the native pickers provide for free.
Do
Use the Figma spec as a visual target for the tokenized wrapper — colors, corner radius, shadow, and ring thickness.
Don't
Don't treat the Figma layout as a 1:1 blueprint — the native picker's internal spacing and cell sizes may not match exactly, and that's fine.
Do
Pass an explicit in: Date... range to enforce minDate/maxDate and rely on the platform's disabled styling.
Don't
Don't style disabled cells manually in consumer code — let the native picker dim them consistently with OS conventions.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Needs Fix | Day cells named by weekday (Sunday, Monday...). Weekday header rows re-use Date Picker - Item rather than a dedicated weekday cell. Scrollbar drawn as a geometry layer. |
| C2 | Variant & Property Naming | Needs Fix | Type=Date | Year | Month is misleading ("Date" means day-grid). Rename to mode=day | month | year. |
| C3 | Token Coverage | Partial | Most colors, spacing, radius, and shadow values are token-bound. Missing a dedicated main/date-picker/group/* scope — panel reuses month-header tokens as a proxy. |
| C4 | Native Mappability | Not Applicable / Rework | Both platforms own the full calendar surface. Scrollbar drawn as geometry, Month missing Prev chevron — neither expressible natively. Recommend wrapping native pickers. |
| C5 | Interaction State Coverage | Needs Fix | Only Today (day) and Selected (year/month) exist. Missing Pressed, Hover/Focus, In-range, Range-start, Range-end, Business-rule Disabled. |
| C6 | Asset & Icon Quality | Needs Fix | Chevrons are raster shape_full image references across all 3 variants. Replace with vector icon instances. |
| C7 | Code Connect Linkability | Not Mapped | Blocked by C1/C2/C4/C5/C6 and by the native-wrapper direction. Map only once the Picker Cell family is unified and the surface is confirmed as a wrapper, not a redraw. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Needs Fix | Rename Type to mode with values day | month | year to match native conventions. |
| Cell primitive unification | Pending | Day / Month / Year cells should become one Picker Cell with kind + state axes before mapping. |
| Native component file | Pending | EBDatePickerPanel wrapper to be created around SwiftUI DatePicker(.graphical) / Compose DatePicker. |
| Raster chevrons | Blocker | Replace with vector icon instances before mapping — raster URLs are not representable in native code. |
| Recommendation | Consolidate | Don't ship as a drawn component. Wrap native pickers and tokenize colors. |
Single axis. 3 variants on Type. All share the 360×296 card frame.
| Type | Dimensions | Header | Grid | Node ID |
|---|---|---|---|---|
| Date | 360 × 296 | Prev · "Month / Year" · Next | 7 × 7 (weekdays + 6 day rows) | 12879:49310 |
| Year | 360 × 296 | Prev · "Year" · Next | 3 × 7 visible + Scrollbar overlay | 18431:2825 |
| Month | 360 × 296 | (empty) · "Year" · Next | 3 × 4 (12 months) | 18431:2826 |
Sunday, Monday...Saturday instead of index/role. Weekday is data, not layer identity. OpenType axis misleading — "Date" means day-grid view. Rename to mode=day | month | year to match SwiftUI / Material 3 terminology. Open18414:6277 is a 4×80 pill absolutely positioned over the Year grid. Native pickers render the scroll indicator automatically; this decoration misrepresents scroll state. OpenDatePicker(.graphical) and Material 3 DatePicker own the full calendar. Recommend wrapping and tokenizing, not redrawing. Openshape_full image URLs. Replace with vector icon instances. Openmain/date-picker/month-header/*. Add a main/date-picker/group/* scope. OpenThe 32×32 day cell rendered inside the Date Picker - Group Type=Date grid. 7 variants across two axes: Type=Default | Today | Selected | Range (Middle) | Prev/Next × State=Enabled | Disabled. Only Default and Today ship with both states; Selected, Range (Middle), and Prev/Next exist only as Enabled. Sibling to Month and Year Picker - Item (100×32 cell) — both are candidates to collapse into a single unified Picker Cell primitive.
Picker Cell with kind: day | month | year + state: default | today | selected | range-middle | prev-next | disabled. Also note: at 32×32 the cell is below WCAG's 44×44 minimum touch target (A11y).The cell is an instance inside Date Picker - Group (Type=Date). 42 cells are rendered across 6 rows × 7 columns. A sibling weekday-header row also instance-swaps this component, which is one of the reasons C1 flags the layer-naming convention.
Switch Type and State to compare the 7 published variants. Range (Middle), Selected, and Prev/Next are only defined as Enabled.
Month and Year Picker - Item exists at 100×32 doing nearly the same job.main/date-picker/day/*. However, Range (Middle) uses absolutely-positioned sibling rectangles (Range highlight start, Range highlight end) to spill the range strip into adjacent cells — this is row-level geometry leaking into a cell-level component.Type mixes display roles (Default, Today, Prev/Next) with selection state (Selected, Range Middle) on one axis. Disabled exists only on Default and Today — Selected, Range (Middle), and Prev/Next have no Disabled variant. Value Range (Middle) uses parentheses/space in a variant name.DatePicker(.graphical) and Material 3 DatePicker render their own cells. Even if kept as a visual reference, it duplicates Month and Year Picker - Item at a different size instead of being one Picker Cell with a kind axis.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | Type=Default, State=Enabled | Plain day number on white. No ring, no fill. #0A2757 label. |
| Today | Yes | Yes | Type=Today, State=Enabled | 1.5px blue ring, blue label. Native equivalent: todayDateBorderColor on Material 3. |
| Selected | Yes | Yes | Type=Selected, State=Enabled | Solid blue fill (#005CE5), white bold label. Only exists as Enabled — no Disabled form. |
| Range (Middle) | Yes | Yes | Type=Range (Middle), State=Enabled | Weakest-info bg (#E5F1FF), bold blue label. Ships with extraLeft/extraRight booleans that bleed the strip into adjacent cells. |
| Prev/Next | Yes | Yes | Type=Prev/Next, State=Enabled | Greyed label (#C2CFE5) for days spilling over from the adjacent month. Not a true Disabled — the day is still conceptually pickable, just visually dimmed. |
| Disabled (Default/Today) | Yes | Yes | Type={Default|Today}, State=Disabled | Label drops to #9BC5FD (Today) or #C2CFE5 (Default). Disabled missing on Selected, Range, and Prev/Next. |
| Pressed / Focused / Hover | No | No | — | Not defined on any Type. Native pickers supply these automatically. |
| Today + Selected | No | No | — | Not defined. Unclear which presentation wins when today is also the selected date. |
| Touch target | 32×32 | 32×32 | — | Below WCAG minimum 44×44. Native pickers enforce their own hit areas, but any custom wrapper would need to extend the tap area beyond the visual cell. |
- Sibling duplication with Month and Year Picker - Item. The two components are the same selectable-cell primitive at different pixel sizes (32×32 vs 100×32) with overlapping state semantics (Default, Today, Selected). Maintaining them as siblings doubles the variant surface and forces the Group panel to decide which cell to instance-swap based on the current view. C1 · Layer Structure & Naming
- Range continuity is drawn per-cell, not at the row. The
Range (Middle)variant includes two absolutely-positioned siblings (Range highlight start,Range highlight end) that spill 28–34% beyond the cell bounds to visually connect with neighbours. Range continuity is row-level geometry; modelling it on the cell produces per-cell decoration with leak. C1 · Layer Structure & Naming Typeaxis mixes display role with selection state. Default, Today, and Prev/Next describe what the cell is; Selected and Range (Middle) describe what the user has done. These belong on two axes (roleandselection), not one. The current shape produces invalid combinations (e.g. you can't express "Today that is also Selected"). C2 · Variant & Property Naming- Variant value
Range (Middle)uses punctuation and whitespace. Rename torange-middle(or, after the axis-split,selection=range-middle). Current value generatestype="Range (Middle)"in code-connect output, which is awkward to match on. C2 · Variant & Property Naming - Cell has no 1:1 native primitive. Both SwiftUI
DatePicker(.graphical)and Material 3DatePickerrender their own day cells and don't accept a custom cell view. This Figma component is therefore a reference spec, not a mappable component — should be marked as such or merged into the Picker Cell family. C4 · Native Mappability - Disabled coverage incomplete. The
Stateaxis nominally supports Disabled, but Disabled variants exist only on Default and Today. Selected, Range (Middle), and Prev/Next have no Disabled form — unreachable if the user picks a selected date then the parent toggles disabled. C5 · Interaction State Coverage - No Pressed, Hover, or Focused variants. Tap feedback (iOS ripple / Android state layer) and keyboard focus ring are not defined. Native pickers supply these automatically, but any custom overlay or wrapper has no tokens to apply. C5 · Interaction State Coverage
- Today + Selected collision is unresolved. There is no variant for the common case of today being the currently-selected date. The design team should decide which presentation wins (ring + fill, fill-only, or a hybrid) and publish a variant. C5 · Interaction State Coverage
- Selection emphasis pattern not shared with Month/Year cells. Selected on this cell is a solid fill; Selected on Month and Year Picker - Item is a 1px ring. The two selection treatments drift — should be one token-driven "selection emphasis" applied consistently per
kind. C6 · Asset & Icon Quality - Code Connect mappings not registered. Blocked by the native-pickers-own-it direction (C4) and by the pending Picker Cell family unification (C1). Map only once the unified component exists and the wrapper surface is confirmed. C7 · Code Connect Linkability
- Family — Consolidate Date Picker - Item + Month and Year Picker - Item into ONE
Picker Cell. Both are selectable cells with identical state semantics (Default / Today / Selected / Disabled); only pixel size (32×32 vs 100×32) and typography differ. Proposed schema:kind=day | month | year+state=default | today | selected | range-middle | prev-next | disabled. Collapses 10 sibling variants across two components into one component with two clean axes. A single nativePickerCellcomposable renders the correct typography perkind. Family - Property — Split
Typeintorole+selection(after consolidation). Within the unified Picker Cell, separate display role (default | today | prev-next) from selection state (none | selected | range-start | range-middle | range-end). Lets designers express "Today that is also Selected" and fixes the current invalid combinations. Property - Rename
Range (Middle)torange-middle. Remove parentheses and whitespace from the variant value. Cleaner code-connect output and matches the kebab-case used by other DS enums. Rename - State — Add Pressed, Focused, and Today+Selected variants. Extend the state axis with Pressed and Focused (needed for any custom wrapper rendering tap / keyboard affordances), and publish a decision variant for the common "today is also selected" case. State
- State — Complete Disabled coverage across all Types. The
Stateaxis currently only exists on Default and Today. Publish Disabled variants for Selected, Range (Middle), and Prev/Next so the axis is rectangular — no missing cells in the variant matrix. State - Composition — Move range-strip continuity from the cell to the row. Remove the absolutely-positioned
Range highlight start/Range highlight endsiblings from the cell. Render the range strip as a row-level decoration behind the cells (a full-width rectangle between range-start and range-end). Keeps the cell component self-contained. Composition - Token — Share a selection-emphasis token across Picker Cell kinds. Create
main/picker-cell/selection/*tokens that resolve to either "fill" or "ring" based onkind, so day/month/year selection treatments stay intentionally consistent rather than drifting. Token - A11y — Flag the 32×32 touch target. Below WCAG 2.5.5 minimum 44×44. If a custom wrapper is ever built, extend the hit area beyond the visual cell (add transparent padding). Document this on the component so consumers don't accidentally ship the tight target outside the native picker context. A11y
- Docs — Mark as reference, not a production component. Given that both platforms render their own day cells inside the native
DatePicker, this cell is a visual reference for the token-styled wrapper, not a component developers rebuild. Add a description banner and a_referenceprefix once the Picker Cell family unification lands. Docs
7 published variants on Type × State. All share a 32×32 frame, 30px pill radius, and Primary/Label/Light/Small typography. Selected and Range (Middle) switch to Primary/Label/Small (bold).
The base day cell. Plain label on white, no ring or fill.
Today marker. 1.5px blue ring, blue label.
Currently-selected date. Solid blue fill, white bold label. No Disabled form.
A cell inside a selected date range. bg/color-bg-info-weakest fill, bold blue label. Ships with extraLeft/extraRight booleans that spill the strip into adjacent cells.
A day from the adjacent month spilling into the current month's grid. Dimmed label, no ring, no fill.
Disabled day. Label dims to text/color-text-disabled (#C2CFE5).
Today on a disabled day. Ring and label both use border/color-border-primary-disabled / text/color-text-primary-disabled (#9BC5FD).
All cell colors are bound to tokens. Selected uses the main/date-picker/day/color/selected/* scope; Default/Today borrow main/date-picker/day/color/unselected/* plus primary text/border tokens for the ring and label. Range (Middle) reuses the shared bg/color-bg-info-weakest rather than a picker-scoped token.
| Type | Role | Token | ENABLED | DISABLED |
|---|---|---|---|---|
| Default | bg | main/date-picker/day/color/unselected/bg | #FFFFFF | #FFFFFF |
| Default | label | main/date-picker/day/color/unselected/label | #0A2757 | #C2CFE5 (text/color-text-disabled) |
| Today | bg | main/date-picker/day/color/unselected/bg | #FFFFFF | #FFFFFF |
| Today | ring | border/color-border-primary | #005CE5 (1.5px) | #9BC5FD (border/color-border-primary-disabled) |
| Today | label | text/color-text-primary | #005CE5 | #9BC5FD (text/color-text-primary-disabled) |
| Selected | bg | main/date-picker/day/color/selected/bg | #005CE5 | – |
| Selected | label | main/date-picker/day/color/selected/label | #FFFFFF | – |
| Range (Middle) | bg | bg/color-bg-info-weakest | #E5F1FF | – |
| Range (Middle) | label | text/color-text-primary | #005CE5 (bold) | – |
| Prev/Next | bg | main/date-picker/day/color/unselected/bg | #FFFFFF | – |
| Prev/Next | label | text/color-text-disabled | #C2CFE5 | – |
| Property | Value |
|---|---|
| Cell size | 32 × 32 |
| Corner radius | 30px (pill) |
| Padding | 10px top, 12px bottom, 6px horizontal |
| Label width | 20px (fixed, centred) |
| Today ring | 1.5px solid |
| Range highlight strip | 32px tall, bleeds ~28–34% beyond cell edges via extraLeft / extraRight |
| Gap (inside grid) | 0 (cells are edge-to-edge in the row) |
| Variant | Text Style | Font | Weight | Size | Line-height | Tracking |
|---|---|---|---|---|---|---|
| Default / Today / Prev/Next | Primary/Label/Light/Small | Proxima Soft | Semibold (600) | 14px | 14px | 0.25px |
| Selected / Range (Middle) | Primary/Label/Small | Proxima Soft | Bold (700) | 14px | 14px | 0.25px |
The day cell is drawn by the native picker. SwiftUI DatePicker(.graphical) and Material 3 DatePicker both render their own day cells and don't expose a custom-cell slot. Token-tint the native picker to match the DS appearance — don't ship a custom EBDatePickerItem composable.
iOS — SwiftUI
DatePicker( "Date of Birth", selection: $birthDate, displayedComponents: .date ) .datePickerStyle(.graphical) .tint(Color.ebBrand) // Selected day fill .accentColor(Color.ebBrand) // Today ring // SwiftUI renders each cell; Today and Selected come from // Calendar.current + the bound selection. No custom cell view.
Android — Jetpack Compose (Material 3)
val state = rememberDatePickerState() DatePicker( state = state, colors = DatePickerDefaults.colors( selectedDayContainerColor = EBColors.brand, // Selected selectedDayContentColor = EBColors.onBrand, todayDateBorderColor = EBColors.brand, // Today ring dayInSelectionRangeContainerColor = EBColors.infoWeakest // Range (Middle) ) ) // Material 3 renders each day cell; prev/next-month cells are dimmed // automatically via dayContentColor variants.
If the product absolutely needs a custom cell (e.g. an event-dot indicator), build a non-native calendar grid and compose PickerCell inside it — but at that point you lose the native a11y and locale handling, which is a significant trade-off.
| Figma Property | SwiftUI Equivalent | Compose Equivalent | Notes |
|---|---|---|---|
| Type=Default | (default rendering) | dayContentColor | Base day cell. Uses primary text token. |
| Type=Today | .accentColor / automatic Today ring | todayDateBorderColor | Native pickers detect Today from Calendar.current; you only supply the ring color. |
| Type=Selected | .tint (via selection binding) | selectedDayContainerColor / selectedDayContentColor | Solid fill. Not a custom cell — selection is derived from the bound Date. |
| Type=Range (Middle) | (no direct API — requires custom calendar) | dayInSelectionRangeContainerColor | Material 3 supports range via DateRangePicker. SwiftUI has no built-in range picker — needs a custom component. |
| Type=Prev/Next | (automatic dimming) | (automatic dimming via dayContentColor) | Adjacent-month days are dimmed by the native picker automatically. No prop needed. |
| State=Disabled | in: Date... range parameter | selectableDates | Enforced via the allowable date range — the picker dims cells outside the range automatically. |
| extraLeft / extraRight | — | — | Figma-only. Range continuity is handled by the native picker or by row-level geometry in a custom component, not per-cell. |
| Requirement | iOS | Android |
|---|---|---|
| Touch target (44 × 44 min) | Figma cell is 32 × 32 — native picker extends hit area | Figma cell is 32 × 32 — native picker extends hit area |
| Screen reader label | VoiceOver: "Friday, 5 March, Today" (from DatePicker) | TalkBack: "5 March 2026, Today" (from Material 3) |
| Selected announcement | "Selected" trait added automatically | "Selected" state added automatically |
| Focus ring | System focus ring on iPad / hw keyboard | System focus indicator on D-Pad / hw keyboard |
| Disabled announcement | "Dimmed" trait when outside in: range | "Disabled" state when outside selectableDates |
| Dynamic Type / font scaling | Automatic | Automatic |
Do
Treat this cell as a visual reference for how the native picker should look in GCash theme — colors, ring thickness, radius, and label weight.
Don't
Don't ship a standalone EBDatePickerItem composable. Native pickers render their own cells; a custom cell composable has no slot to plug into.
Do
If you genuinely need a custom cell (e.g. event dots, legend markers), merge with Month and Year Picker - Item into a unified PickerCell(kind:, state:) and compose it inside a custom calendar grid.
Don't
Don't maintain Date Picker - Item and Month and Year Picker - Item as siblings — they're the same primitive at different sizes.
Do
Rely on the native picker for locale-aware firstDayOfWeek, leap-year handling, VoiceOver / TalkBack, and minDate/maxDate enforcement.
Don't
Don't draw disabled days manually. Pass an in: range (SwiftUI) or selectableDates (Compose) and let the platform style them.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Needs Fix | Sibling-duplication with Month and Year Picker - Item. Range continuity modelled with absolute-positioned siblings that spill beyond cell bounds. |
| C2 | Variant & Property Naming | Needs Fix | Type mixes display role with selection state on one axis. Value Range (Middle) uses punctuation/whitespace. |
| C3 | Token Coverage | Pass | All colors, spacing, radius, and typography are token-bound (main/date-picker/day/* + primary text/border tokens). |
| C4 | Native Mappability | Not Applicable / Rework | Native pickers own the day cell and don't accept a custom cell view. Reference-only unless merged into a custom PickerCell. |
| C5 | Interaction State Coverage | Needs Fix | Disabled missing on Selected, Range (Middle), Prev/Next. No Pressed, Hover, Focused. Today + Selected collision unresolved. |
| C6 | Asset & Icon Quality | Partial | No raster assets — cell is pure geometry + text. But selection emphasis (fill vs ring) drifts from Month and Year Picker - Item; should be one token-driven pattern. |
| C7 | Code Connect Linkability | Not Mapped | Blocked by C4 (native owns it) and by the pending Picker Cell family unification. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Needs Fix | Split Type into role + selection; rename Range (Middle) to range-middle. |
| Family unification | Pending | Merge with Month and Year Picker - Item into PickerCell with kind: day | month | year. |
| Native component file | Pending | No standalone composable — native DatePicker renders cells. Only materialize EBPickerCell if a custom calendar grid is ever built. |
| Range continuity | Blocker | Move Range highlight start/end geometry from the cell up to the row before mapping. |
| Recommendation | Consolidate | Merge into PickerCell, mark as reference spec for the native picker's day cell. |
5 Type × 2 State would produce 10 variants, but only 7 are published — Selected, Range (Middle), and Prev/Next exist only with State=Enabled.
| Type | State | Dimensions | Emphasis | Node ID |
|---|---|---|---|---|
| Default | Enabled | 32 × 32 | none | 12874:42181 |
| Default | Disabled | 32 × 32 | dim label | 13948:3888 |
| Today | Enabled | 32 × 32 | 1.5px blue ring | 13944:5633 |
| Today | Disabled | 32 × 32 | 1.5px primary-disabled ring | 13948:3891 |
| Selected | Enabled | 32 × 32 | solid blue fill, white label | 12874:42183 |
| Range (Middle) | Enabled | 32 × 32 | weakest-info fill, bold blue label, extraLeft/extraRight booleans | 13944:5637 |
| Prev/Next | Enabled | 32 × 32 | dim label only | 13944:5653 |
Missing combinations: Selected · Disabled, Range (Middle) · Disabled, Prev/Next · Disabled. Also missing across all Types: Pressed, Focused, Hover, Today + Selected.
Type × State. 32×32 pill cell with token-bound colors, spacing, radius, and typography. DocumentedPicker Cell with kind: day | month | year. OpenRange highlight start and Range highlight end are absolute-positioned siblings that spill beyond the cell. Should be row-level geometry. OpenType mixes role and selection — Default/Today/Prev-Next are display roles; Selected/Range (Middle) are selection states. Split into role + selection. OpenRange (Middle) needs cleanup — Rename to range-middle for clean code-connect output. OpenDatePicker renders its own cells on both platforms. Reference-only unless a custom calendar grid is built. Openkind. OpenA field-shaped trigger that opens an inline calendar panel. 5 usable variants across State (Default/Active) × isFilled (true/false) × isDisabled (Yes/No) — of a 2×2×2=8 matrix, only 5 combinations are defined because Disabled collapses both State values into a single visual. Lead component of the Date Picker family (Group, Item, Month/Year Picker - Item).
DatePicker mapping.Contexts are illustrative. Final screens will reference actual GCash patterns.
Toggle state, fill, and disabled to see the trigger update. Active state shows the inline calendar panel.
isDisabled uses Yes/No (should be true/false). Axis design is ambiguous — Disabled is conceptually a state, not orthogonal to State, yielding 5 usable of 8 combinations. Diverges from Input Field / Select Field pattern which use a single State enum including Disabled.DatePicker / Compose DatePickerDialog.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default (empty) | Yes | Yes | State=Default, isFilled=false | Gray #D7E0EF border. Placeholder "Value" #90A8D0. |
| Default (filled) | Yes | Yes | State=Default, isFilled=true | Gray border, selected date shown in #0A2757. |
| Active (opening — empty) | Yes | Yes | State=Active, isFilled=false | Blue #005CE5 2px border. Inline calendar panel attached below. |
| Active (filled) | Yes | Yes | State=Active, isFilled=true | Blue border, filled value, calendar visible. |
| Disabled | Yes | Yes | isDisabled=Yes | Gray #EEF2F9 bg. Value #90A8D0. No border. Calendar glyph dims. |
| Error | No | No | — | Not defined. Sibling Select/Input Field both support Error — must be added for validation flows (e.g. "Enter a valid birth date"). |
| Pressed | No | No | — | Not defined. Touch feedback expected on both platforms. |
- Layer naming inconsistent with sibling fields. Trigger frame is named
Select Fieldand its innercontainer/text-containerare generic — but the component itself isDate Picker. The Date Picker - Group popover has layer names likerow,Monday...Saturday(day-of-week labels as layer names rather than semantic roles). Normalize to kebab-case semantic names. C1 · Layer Structure & Naming isDisabledusesYes/No,isFilledusesFalse/True. Should be lowercasetrue/falsefor direct SwiftBool/ KotlinBooleanmapping. Inconsistent casing between the two booleans also breaks Code Connect naming uniformity. C2 · Variant & Property Naming- Axis design produces invalid combinations.
State × isFilled × isDisabledis a 2×2×2=8 matrix but only 5 combinations exist because Disabled collapses State. Siblings (Input Field, Select Field) use a singleStateenum includingDisabled— Date Picker should follow that pattern. C2 · Variant & Property Naming - Calendar popover is composed inline, not as an overlay. Date Picker - Group is nested inside the trigger's stack and positioned absolutely — it cannot be swapped for a sheet or presented by the native date dialog. SwiftUI
DatePickerand ComposeDatePickerDialogboth expect the trigger and the picker to be separable. C4 · Native Mappability - No Error state. Input Field, Select Field, and Dropdown all support Error; Date Picker does not. Birth-date and expiry flows routinely need inline validation ("Must be 18+", "Date can't be in the past"). C5 · Interaction State Coverage
- No Pressed state. Standard tap feedback (iOS highlight, Android ripple) is missing. Consumers improvise it per-screen. C5 · Interaction State Coverage
- Calendar glyph is a raster
shape_fullimage. The trigger icon pulls from an MCP asset URL rather than a vector icon instance. Won't scale cleanly, won't inherit state tokens, can't be swapped. C6 · Asset & Icon Quality - Code Connect mappings not registered. Blocked by C1/C2/C4/C5/C6 above. C7 · Code Connect Linkability
- Family — Unify the two cell primitives.
Date Picker - Item(day cells) andMonth and Year Picker - Item(month/year cells) are both selectable cells with identical state semantics (Default/Today/Selected/Disabled). Only size + typography differ. Propose onePicker Cellcomponent with axeskind: day | month | year+state: default | today | selected | disabled. Reduces 10 variants across 2 components to 1 component with 2 clean axes. Family - Composition — Wrap the native date pickers instead of redrawing them. iOS:
DatePicker(selection:, displayedComponents:)with.datePickerStyle(.graphical|.compact|.wheel|.field). Android: Material 3DatePicker+DatePickerDialog. The DSEBDatePickershould be a thin wrapper that tokenizes the trigger and cells to match brand — not a from-scratch calendar redraw in Figma. Keeps accessibility, localization, and leap-year logic native. Composition - Field-trigger consistency — Unify with Input Field as
type: .date. Structurally this is an Input Field with a date value display + calendar glyph. Matches SwiftUITextField(value:, format: .date)/ ComposeOutlinedTextField(readOnly=true) + DatePickerDialog. Consolidates a fragmented field family and inherits Input Field's Error state, label slot, and helper text. Family - Collapse
State × isFilled × isDisabledinto a singlestateenum. Target schema:state: default | active | error | disabled+isFilled: true | false. Matches Input Field (8 variants from 4×2). Removes the 3 invalid combinations in the current matrix and aligns with the rest of the Form Elements family. Property - Separate the calendar panel from the trigger. Move Date Picker - Group to a true overlay component (top-anchored, dismissible, shadow-elevated) rather than an inline child of the trigger. Lets the trigger be used alone (e.g. inline text input mode) and lets the panel be presented by either a dropdown or a sheet depending on context. Composition
- Add Error and Pressed states. Error: red border + subtext (mirror Input Field tokens). Pressed: slight bg tint for tap feedback. Required for form accessibility and parity with the rest of Form Elements. State
- Replace the calendar raster with a vector icon instance. Use the existing DS calendar icon (if present) or add one to the icon library. Color-bind to
selected-field/color/{state}/iconso it dims with Disabled automatically. Asset - Rename booleans to lowercase
true/false.isDisabled=Yes/NotoisDisabled=true/false;isFilled=False/TruetoisFilled=false/true. Consistent with the naming convention adopted by Input Field. Rename
5 usable variants from a 2×2×2 State × isFilled × isDisabled matrix. Disabled collapses both State values into one visual. All share the 360×68px trigger container; Active expands to show the inline calendar panel.
Idle trigger with gray border and placeholder text. Calendar glyph visible on the right.
Trigger showing a selected date. Gray border, filled text color #0A2757.
Trigger focused with 2px blue border. Inline calendar panel attached below showing month header, weekday row, and date grid.
Trigger focused with 2px blue border and filled value. Calendar visible with the selected day highlighted in the grid.
Non-interactive. Gray #EEF2F9 bg, no border, value dims to #90A8D0. Calendar glyph dims. Only isFilled=true is defined in Disabled.
Trigger colors reuse the selected-field token family (shared with Dropdown and Select Field). Calendar panel uses dedicated date-picker tokens.
| Role | Token | DEFAULT | ACTIVE | DISABLED |
|---|---|---|---|---|
| Trigger border | selected-field/color/{state}/border | #D7E0EF | #005CE5 (2px) | hidden |
| Trigger bg | selected-field/color/{state}/bg | #FFFFFF | #FFFFFF | #EEF2F9 |
| Value (filled) | selected-field/color/{state}/value | #0A2757 | #0A2757 | #90A8D0 |
| Placeholder | selected-field/color/{state}/placeholder | #90A8D0 | #90A8D0 | – |
| Calendar icon | selected-field/color/{state}/icon | #005CE5 | #005CE5 | #9BC5FD |
| Header label | formgroup-header/color/label | #0A2757 | #0A2757 | #0A2757 |
| Panel bg | date-picker/month-header/color/bg | – | #FFFFFF | – |
| Panel border | date-picker/month-header/color/border | – | #E5EBF4 | – |
| Panel shadow | elevation/app/shadow | – | 0 6px 12px rgba(2,14,34,.16) | – |
| Month label | date-picker/month-header/color/label | – | #0A2757 | – |
| Month chevron | date-picker/month-header/color/icon | – | #005CE5 | – |
| Weekday label | date-picker/week-header/color/label | – | #0A2757 | – |
| Day (unselected) | date-picker/day/color/unselected/label | – | #0A2757 | – |
| Day (today border) | border/color-border-primary | – | #005CE5 (1.5px) | – |
| Day (prev/next month) | text/color-text-disabled | – | #C2CFE5 | – |
| Property | Value |
|---|---|
| Component width | 360px (fixed) |
| Trigger height | 46px |
| Trigger height (Active, empty) | 46px (border 2px inset) |
| Corner radius (trigger) | 6px (radius/radius-2) |
| Trigger padding | 6px top, 8px bottom, 12px horizontal |
| Calendar icon size | 32 × 32 (glyph ~24 × 25 inside) |
| Panel padding | 16px all sides |
| Panel corner radius | 8px top-left/top-right, 6px bottom-left/bottom-right |
| Panel gap (rows) | 8px |
| Day cell size | 32 × 32 |
| Day cell radius | 30px (pill) |
| Month chevron size | 24 × 24 |
| Header label padding-bottom | 8px |
| Layer | Text Style | Font | Size | Tracking | Line-height |
|---|---|---|---|---|---|
| Header label | Primary/Label/Light/Small | Proxima Soft Semibold | 14px | 0.25px | 14px |
| Trigger value / placeholder | Primary/Label/Light/Small | Proxima Soft Semibold | 14px | 0.25px | 14px |
| Month / Year label | Primary/Label/Large | Proxima Soft Bold | 18px | 0.25px | 18px |
| Weekday label | Primary/Label/Small | Proxima Soft Bold | 14px | 0.25px | 14px |
| Day label | Primary/Label/Light/Small | Proxima Soft Semibold | 14px | 0.25px | 14px |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:date-picker:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.datepicker.* // Compose
Package not yet published. These are the planned distribution paths. The recommendation is to wrap native date pickers rather than redraw them.
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| State=Default | — | — | Default idle state (picker not shown) |
| State=Active | isPresented: Binding<Bool> | showPicker: Boolean | Calendar panel/dialog visible |
| isFilled (False/True) | selection: Binding<Date?> | selectedDate: LocalDate? | Derived from non-null selection |
| isDisabled (Yes/No) | .disabled(true) | enabled=false | Non-interactive state |
| label (formgroup-header) | label: String | label: String | Header text above the trigger |
| subtext (optional) | helperText: String? | helperText: String? | Hint / error message below trigger |
EBDatePicker("Date of Birth", selection: $birthDate)
EBDatePicker( label = "Date of Birth", selectedDate = birthDate, onDateSelected = { birthDate = it } )
EBDatePicker("Date of Birth", selection: $birthDate) .ebHelperText("You must be 18 or older")
EBDatePicker( label = "Date of Birth", selectedDate = birthDate, onDateSelected = { birthDate = it }, helperText = "You must be 18 or older" )
EBDatePicker("Date of Birth", selection: $birthDate) .disabled(true)
EBDatePicker( label = "Date of Birth", selectedDate = birthDate, onDateSelected = { birthDate = it }, enabled = false )
| Requirement | iOS | Android |
|---|---|---|
| Minimum touch target | 44 × 44 pt | 48 × 48 dp |
| Accessibility label | .accessibilityLabel("Date of Birth") | contentDescription="Date of Birth" |
| Selected value | .accessibilityValue(formattedDate) | semantics { stateDescription } |
| Role | VoiceOver announces as date picker | TalkBack announces via Role.Button + expanded state |
| Localization | Native DatePicker respects locale calendar | Native DatePickerDialog respects locale calendar |
Do
Wrap the native DatePicker / DatePickerDialog so accessibility, localization, and leap-year logic are handled by the OS.
Don't
Redraw the calendar grid from scratch — the Figma calendar is a visual spec, not a reimplementation target.
Do
Pair the trigger with a visible label above it. Use helper text to clarify expected range ("Pick a future date").
Don't
Use the trigger as a free-text date field — it's for picking from the panel, not typing. If free-entry is required, use Input Field with a date format.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Partial | Trigger reuses Select Field / container / text-container. Day cell rows use weekday names (Monday…Saturday) as layer names. |
| C2 | Variant & Property Naming | Needs Fix | isDisabled=Yes/No, isFilled=False/True. Inconsistent casing. Axis design yields 5 of 8 combinations. |
| C3 | Token Coverage | Ready | All colors bound to selected-field and date-picker tokens. Spacing and radius tokens consistent. |
| C4 | Native Mappability | Needs Fix | Inline calendar panel blocks mapping to native DatePicker / DatePickerDialog. Must be separable. |
| C5 | Interaction State Coverage | Needs Fix | No Error state (siblings have it). No Pressed state. |
| C6 | Asset & Icon Quality | Needs Fix | Calendar glyph is a raster shape_full image. Month chevrons use shape_full images as well. |
| C7 | Code Connect Linkability | Pending | Blocked by C1/C2/C4/C5/C6. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Blocked | isDisabled/isFilled need lowercase boolean values |
| Axis design | Blocked | Collapse to single state enum matching Input/Select Field pattern |
| Composition | Blocked | Calendar panel must be separable to map native dialog/sheet |
| Asset quality | Blocked | Calendar glyph + chevrons must be vector icons |
| State coverage | Blocked | Error and Pressed states missing |
| Native component file | Pending | EBDatePicker.swift / EBDatePicker.kt not yet created |
Declared as a 2×2×2 matrix (State × isFilled × isDisabled) but only 5 combinations exist — Disabled collapses both State and isFilled=false into a single variant.
| State | isFilled | isDisabled | Node ID |
|---|---|---|---|
| Default | false | No | 12879:49784 |
| Default | true | No | 12890:42872 |
| Active | false | No | 12879:49827 |
| Active | true | No | 13342:9932 |
| Default | true | Yes | 13342:10148 |
State × isFilled × isDisabled. Field-shaped trigger with inline calendar panel on Active. Lead component of the Date Picker family. Documentedstate enum like Input/Select Field. OpenisDisabled=Yes/No and isFilled=False/True. Both need lowercase true/false for Code Connect. OpenDatePicker / DatePickerDialog. Openshape_full image reference, not a vector icon instance. Month chevrons also use raster shape_full assets. OpenThe popover/menu surface that appears when a Dropdown trigger is tapped. Stacks Dropdown Items vertically inside a 6px rounded card with a drop shadow. Today it is a fixed layout of 8 pre-nested Dropdown Items with no slot or count parameter — acting more as a visual preview than a reusable component. Native platforms handle this surface via Menu (iOS) and DropdownMenu (Material 3), so this Figma component should be consolidated into Dropdown's composition pattern rather than shipped as its own DS primitive.
Dropdown component's expanded state as inline overlay behavior, not a separate component.The group appears immediately below a Dropdown trigger in the expanded state. On native platforms it is rendered by the OS menu primitive, not drawn manually.
The group renders a vertical stack of Dropdown Item rows inside a rounded card with drop shadow. Only one visual state.
Dropdown - Item (node 6383:3442) instead of a DropdownItem component instance — breaking the self-contained promise.Dropdown Item - Group) which doesn't match other DS naming ("Avatar Group", "Button Group").items property, no variant axis. Cannot be nested inside Dropdown as an overlay — the Dropdown's Expanded variant draws its own list instead of referencing this group. Effectively a preview artifact, not a component.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default (open) | Yes | Yes | — | White bg, 6px radius, drop shadow. Rendered by Menu (iOS) / DropdownMenu (Android) automatically. |
| Item hover/press | Yes | Yes | — | Handled by the per-item Dropdown Item component, not the group. Touch feedback is platform-native. |
| Scroll | Yes | Yes | — | Native menus clip and scroll automatically when item count exceeds available height. |
- Last row is a detached frame, not a component instance. The eighth row (node
6383:3442,Dropdown - Item) is a hand-built frame with inline styles instead of a DropdownItem instance. Breaks the group's consistency and means any DropdownItem property change will not propagate to the last row. C1 · Layer Structure & Naming - No slot, no item count, no width parameter. The group is a fixed layout of 8 rows at 366px. There is no
itemsslot, no numeric count property, and no fill-container option. Consumers cannot build a 3-item menu or a wider menu without rebuilding it. C1 · Layer Structure & Naming - Component name uses irregular " - " separator.
Dropdown Item - Groupdoesn't match other DS group naming (Avatar Group,Button Group,List). Consider renaming toDropdown Menuor folding underDropdown/Menu. C2 · Variant & Property Naming - Popover surface does not exist as a separate native primitive. Both SwiftUI (
Menu) and Compose (DropdownMenu) handle the surface — shadow, radius, bg, positioning, clipping — automatically. Modeling it as a standalone Figma component creates a phantom that cannot be Code Connect-mapped 1:1. C4 · Native Mappability - Last row has a bottom border. Every row in the group carries
border-bincluding the final row, producing a redundant separator flush with the card's bottom edge. Should be removed on the last item or moved to a top-border-all-except-first pattern. C1 · Layer Structure & Naming - Code Connect mappings not registered. If the component is kept, it has no parameters to map. If consolidated into Dropdown, it will not need its own mapping. C7 · Code Connect Linkability
- Consolidate into the
Dropdowncomponent's Expanded state. The popover surface is not independently reusable — it always pairs with a Dropdown trigger. Remove this as a separate DS primitive and express the overlay via Dropdown'stype=Expandedvariant. NativeMenu/DropdownMenuprimitives render the surface automatically. Family - Alternatively, convert to a Slot-based container. If kept, replace the hardcoded 8 instances with a Figma Slot accepting any number of Dropdown Items. Rename to
Dropdown Menu. Set width to fill-container so the parent trigger decides sizing. Slot - Replace the detached last row with a DropdownItem instance. Node
6383:3442is a hand-built frame — swap it for a DropdownItem component instance so property changes propagate uniformly. Composition - Remove the bottom border on the last item. Use a
::not(:last-child)-equivalent pattern (separator between rows, not after the final row) to avoid the double line against the card's bottom edge. Property - Rename to
Dropdown Menu(if kept). "Dropdown Item - Group" reads as a group of items; "Dropdown Menu" matches the native primitive (Menu/DropdownMenu) and reads better in the component picker. Rename - Document as an internal artifact, not a shipped component. If the team keeps it purely for Figma layout previews, mark it
_internalor move it to a Hidden page so it doesn't appear in the public component picker. Docs
Single variant. No property axes. Fixed 8-row layout at 366px width.
Rounded card surface containing a vertical stack of Dropdown Items. 6px corner radius, white background, 12px-blur drop shadow at 6px offset.
Display-only surface. The group itself only contributes bg, shadow, and row dividers — all per-item colors come from Dropdown Item.
| Role | Token | VALUE |
|---|---|---|
| Surface bg | bg/color-bg-main | #FFFFFF |
| Row divider | main/dropdown-item/color/default/border | #E5EBF4 |
| Shadow color | elevation/app/shadow/color-shadow | #020E2229 |
| Shadow border | elevation/app/shadow/color-border | #FFFFFF00 |
| Item label | main/dropdown-item/color/default/label | #0A2757 |
| Property | Value |
|---|---|
| Width | 366px (fixed) |
| Item count | 8 (fixed) |
| Corner radius | 6px |
| Item padding | 16px vertical, 12px left, 16px right |
| Item gap | space-8 (0px effective) |
| Row divider | 1px bottom border per row |
| Shadow offset | 0 6px (x y) |
| Shadow blur | 12px |
| Shadow spread | -8px |
| Layer | Text Style | Font | Size | Tracking | Line-height |
|---|---|---|---|---|---|
| Dropdown item label | Primary/Label/Light/Large | Proxima Soft Semibold | 18px | 0.25px | 18px |
This surface does not require a dedicated native component. Both iOS and Android expose menu primitives that render a shadowed card around items automatically. If the EB Dropdown is built using those primitives, no additional "group" wrapper is needed.
iOS — SwiftUI Menu
Menu("Category") { ForEach(items) { item in Button(item.name) { select(item) } } } // The shadowed card surface is drawn by SwiftUI, not by the app.
Android — Jetpack Compose DropdownMenu
DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { items.forEach { item -> DropdownMenuItem( text = { Text(item.name) }, onClick = { select(item) } ) } } // Material 3 renders the card, shadow, and clipping automatically.
If the design team wants a custom overlay (e.g. to override shadow or radius), a thin EBDropdownMenu wrapper can be added — but the Figma component should still act as a slot, not a fixed 8-item layout.
| Figma Property | SwiftUI Equivalent | Compose Equivalent | Notes |
|---|---|---|---|
| (no properties) | Menu / View ZStack | DropdownMenu | No variant axis, no slot, no count. Nothing to map. |
| Width 366px | — | — | Native menus size to the anchor / content automatically. |
| Item count 8 | ForEach(items) | items.forEach | Consumers control the count via the collection they pass in. |
| Requirement | iOS | Android |
|---|---|---|
| Menu role | Automatic via Menu | Automatic via DropdownMenu (Role.DropdownList) |
| Focus trap | VoiceOver moves focus into the menu on open | TalkBack moves focus into the menu on open |
| Dismiss on outside tap | Automatic | onDismissRequest |
| Max visible items | Native scroll when exceeded | Native scroll when exceeded |
Do
Render the menu surface through SwiftUI's Menu or Compose's DropdownMenu. Let the platform handle shadow, clipping, positioning, and keyboard dismissal.
Don't
Don't hand-draw a shadowed card and place items inside it. You'll reimplement focus management, scroll clipping, and accessibility semantics that the platform gives you for free.
Do
Keep Dropdown Item as the per-row component. Use it inside the native menu's builder closure (SwiftUI) or inside DropdownMenuItem's text slot (Compose).
Don't
Don't use this Figma component as a layout reference for production — the fixed 8-row, 366px-wide, detached-last-row structure won't match real menu content.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Needs Fix | Fixed 8-item layout with no slot. Last row is a detached frame (6383:3442), not a DropdownItem instance. Component name uses irregular " - " separator. |
| C2 | Variant & Property Naming | Partial | No variant properties exist. Component name Dropdown Item - Group is irregular; prefer Dropdown Menu. |
| C3 | Token Coverage | Ready | All colors, radius, spacing, and shadow values bound to tokens (bg/color-bg-main, elevation/app/shadow/*, space/space-*). |
| C4 | Native Mappability | Not Applicable | Popover surface is rendered by Menu / DropdownMenu natively. No 1:1 component to map to. |
| C5 | Interaction State Coverage | Ready | Surface has no interactive states of its own. Per-item states live on Dropdown Item. |
| C6 | Asset & Icon Quality | Ready | No icons or raster assets. Pure layout + shadow. |
| C7 | Code Connect Linkability | Not Applicable | No native component to map to. Consolidate into Dropdown instead. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | N/A | No properties exist on the component. |
| Slot coverage | Missing | No items slot — blocks any meaningful mapping. |
| Native component file | N/A | Handled by Menu/DropdownMenu. No dedicated EB component required. |
| Recommendation | Consolidate | Fold into Dropdown's Expanded variant or convert to a slot-based Dropdown Menu before mapping. |
Single variant, no property axes.
| Variant | Width | Item Count | Node ID |
|---|---|---|---|
| Default | 366px | 8 (7 instances + 1 detached frame) | 6383:3446 |
6383:3442 is a hand-built Dropdown - Item frame instead of a DropdownItem component instance. Breaks consistency. OpenMenu (iOS) and DropdownMenu (Compose) draw the shadowed card automatically. This component has no 1:1 native mapping. OpenThe row primitive used inside Dropdown and Dropdown Item Group. 9 variants across type (text / amount / country / text with tag / disabeld) × selected (true/false). Carries the label, optional badge tag, peso amount, or country flag + dial code, plus a bottom divider. Selected state uses brand blue for the label; disabled uses a soft fill and muted label.
disabeld (C2). Country variant uses a raster PNG flag (C6). No pressed/focused state variants (C5). Disabled is modeled as a type value rather than an orthogonal state (C4).Dropdown Item is the row primitive consumed by the Dropdown overlay and by Dropdown Item Group. Not used standalone.
Toggle type and selected to see the row update in real time.
main/dropdown-item/color/*.disabeld is misspelled and ships in the generated TypeScript type. Disabled is modeled as a type rather than an orthogonal state, so you cannot combine it with text / amount / country cleanly.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default (unselected) | Yes | Yes | selected=false | Label #0A2757, bottom border #E5EBF4. |
| Selected | Yes | Yes | selected=true | Label #005CE5 (brand), same divider. No background highlight — relies on text color alone. |
| Disabled | Yes | Yes | type=disabeld | Soft fill #F6F9FD, label #C2CFE5. Modeled as a content type rather than a state (C4). |
| Pressed | No | No | — | Not defined. iOS highlight and Android ripple will have to be improvised at instance level. |
| Focused | No | No | — | Not defined. Required for keyboard / D-pad navigation in dropdown overlays. |
- Enum value
disabeldis misspelled. The variant name ships into the generated TS type (type?: "text" | "amount" | "country" | "text with tag" | "disabeld"). Every consumer has to mirror the typo or lose autocomplete. Rename todisabled. C2 · Variant & Property Naming - Country flag is a raster PNG. The
countryvariant embeds a Philippines image (imgPhilippines) as a raster fill, not a vector flag instance. Blocks clean native handoff and freezes the row to a single locale. C6 · Asset & Icon Quality - No pressed or focused state variants. Only
selectedon/off plus a pseudo-disabled type. Touch feedback (iOS highlight, Android ripple) and keyboard focus are not covered at the DS layer. C5 · Interaction State Coverage - Disabled is modeled as a
typevalue. It collides with content types (text, amount, country) — you can't express "amount + disabled" or "country + disabled" in the current schema. Should be an orthogonalstate/disabledaxis. C4 · Native Mappability - No selected-visual affordance beyond label color. Selected state only changes label hex (#0A2757 → #005CE5). A checkmark trailing slot or background fill would make the picked item unambiguous, especially for color-blind users. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked by the typo, missing states, and raster asset above. C7 · Code Connect Linkability
- Rename
disabeldenum value todisabled. Zero visual change, fixes the type surface, and unblocks Code Connect naming hygiene. Rename - Promote disabled to its own axis. Split the current 5-value
typeinto two props:type(text / text with tag / amount / country) ×disabled(true / false). Matches how every native primitive models enabled/disabled and collapses the matrix to 4 × 2 × 2 (selected)=16 compositional variants with clean semantics. Property - Replace the raster Philippines flag with a vector flag slot. Introduce a
leadingAssetslot (or `flag` slot) that accepts a vector flag instance. Current PNG blocks reuse for any non-PH locale and ships a raster asset to native. Slot - Add pressed and focused state variants. Define a state axis so iOS highlight and Android ripple map to tokenized backgrounds instead of being improvised at instance level. State
- Add an explicit selected-visual affordance. A trailing checkmark (or tokenized background fill) on
selected=trueremoves the reliance on label color alone — improves accessibility and scannability. State - Generalize the amount variant around a trailing value slot. Instead of a baked-in peso + amount text, expose a trailing content slot that the peso sign and amount compose into. Opens the row to reuse for any key/value pair (balance, fee, exchange rate). Slot
- Register Code Connect mapping to
EBDropdownItem. After the rename and state work, wire the Figma properties 1:1 to the SwiftUI / Compose API. Docs
9 variants across type (text / amount / country / text with tag / disabeld) × selected (true/false). Note: disabeld only ships with selected=false, so the matrix is 4 × 2 + 1=9.
Plain text row. Default content type used by Dropdown. Label switches from neutral #0A2757 (default) to brand #005CE5 (selected).
Row with a trailing Badge instance (Negative/Heavy variant in stock). Used when an option needs an inline status label.
Peso sign (vector, Proxima-sized) + amount text. Icon currency token flips to brand on selected.
Leading flag (25 × 16, 2px radius) + country name and dial code. Flag is a raster PNG, not a vector instance — open issue (C6).
Soft fill row with muted label. Currently only exists at selected=false. Enum value is misspelled (disabeld) — open issue (C2).
All color roles are bound to the main/dropdown-item/color/* token family. Dropdown Item has no variable modes — colors are keyed by state only.
| ROLE | TOKEN | DEFAULT | SELECTED | DISABLED |
|---|---|---|---|---|
| Row bg | main/dropdown-item/color/{state}/bg | transparent | transparent | #F6F9FD |
| Label | main/dropdown-item/color/{state}/label | #0A2757 | #005CE5 | #C2CFE5 |
| Bottom border | main/dropdown-item/color/{state}/border | #E5EBF4 | #E5EBF4 | #E5EBF4 |
| Peso sign (amount) | main/dropdown-item/color/{state}/icon-currency | #0A2757 | #005CE5 | – |
| Badge bg (text with tag) | main/badge/negative/heavy/background | #D61B2C | #D61B2C | – |
| Badge label (text with tag) | main/badge/negative/heavy/label | #FFFFFF | #FFFFFF | – |
| Property | Value | Token |
|---|---|---|
| Row width | 366px (fill) | — |
| Row height | 50px (text / text with tag) · 51.2–52px (amount / country) | — |
| Padding top/bottom | 16px | space/space-16 |
| Padding left | 12px | space/space-12 |
| Padding right | 16px | space/space-16 |
| Gap (country / text with tag) | 8px | space/space-8 |
| Flag size (country) | 25 × 16 | — |
| Flag radius | 2px | radius/radius-1 (approx) |
| Peso sign size (amount) | 18 × 18 | — |
| Bottom border | 1px solid | main/dropdown-item/color/{state}/border |
| Row corner radius | 0 | radius/radius-0 |
| Layer | Text Style | Font | Size | Tracking | Line-height |
|---|---|---|---|---|---|
| Label (all types) | Primary/Label/Light/Large | Proxima Soft Semibold | 18px | 0.25px | 18px |
| Amount text | Primary/Label/Light/Large | Proxima Soft Semibold | 18px | 0.25px | 18px |
| Badge label (text with tag) | Primary/Label/Fine | Proxima Soft Bold | 12px | 0.5px | 12px |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:dropdown:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.dropdown.* // Compose
Dropdown Item is bundled with the Dropdown package. The planned SwiftUI API exposes it as EBDropdownItem; on Compose it maps to Material 3's DropdownMenuItem with EB-styled content.
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| type=text | EBDropdownItem(label:) | DropdownMenuItem(text={ Text(label) }) | Default content type |
| type=text with tag | EBDropdownItem(label:tag:) | trailing={ EBBadge(…) } | Trailing Badge slot (Negative/Heavy in stock) |
| type=amount | .ebStyle(.amount) | style=EBDropdownItemStyle.Amount | Leading peso sign + trailing amount text |
| type=country | EBDropdownItem(flag:name:dialCode:) | leadingIcon={ FlagIcon(…) } | Flag must be a vector instance — raster PNG is an open issue (C6) |
| type=disabeld Typo | .disabled(true) | enabled=false | Should be an orthogonal disabled prop, not a type value (C4) |
| selected=true | isSelected: Bool | selected: Boolean | Switches label + icon-currency token to active |
| selected=false | isSelected: Bool (default) | selected: Boolean (default) | Default unselected state |
EBDropdownItem("Dropdown Item") .isSelected(category == "item") .onTap { category = "item" }
EBDropdownItem( label = "Dropdown Item", selected = selected == "item", onClick = { selected = "item" } )
EBDropdownItem("Dropdown Item") { EBBadge("Label", level: .heavy, state: .negative) }
EBDropdownItem( label = "Dropdown Item", trailing = { EBBadge("Label", level = EBBadgeLevel.Heavy, state = EBBadgeState.Negative) } )
EBDropdownItem(amount: "1,000.00") .ebStyle(.amount)
EBDropdownItem( label = "1,000.00", style = EBDropdownItemStyle.Amount )
EBDropdownItem( flag: Image("flag_ph"), name: "Philippines", dialCode: "+63" )
EBDropdownItem( label = "Philippines +63", leadingIcon = { FlagIcon(CountryCode.PH) } )
| Requirement | iOS | Android |
|---|---|---|
| Minimum touch target | 44 × 44 pt (row is 50pt tall) | 48 × 48 dp |
| Accessibility label | .accessibilityLabel("Philippines, +63") | contentDescription="Philippines, +63" |
| Role | .accessibilityAddTraits(.isButton) | Role.Button |
| Selected announcement | .accessibilityAddTraits(.isSelected) | selected=true in semantics |
| Disabled announcement | .disabled(true) → VoiceOver says "dimmed" | enabled=false → TalkBack says "disabled" |
Do
Consume Dropdown Item through Dropdown or Dropdown Item Group. Keep labels short — 18px Semibold is the only supported text size.
Don't
Don't use Dropdown Item outside a dropdown overlay. For standalone list rows, use the List Item component instead.
Do
Pair selected state with a trailing checkmark (once added) so the picked item is unambiguous, especially on the country and amount variants.
Don't
Don't rely on the disabeld type to build disabled versions of amount or country — it only ships for the text content type. Use the planned disabled state axis instead.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic names: container, name, offset, Peso Sign - Proxima, Field Trailing Flag, philippines. |
| C2 | Variant & Property Naming | Needs Fix | Enum value disabeld is misspelled and ships into the generated TS type. |
| C3 | Token Coverage | Ready | All colors bound to main/dropdown-item/color/*; space + radius + typography tokens all present. |
| C4 | Native Mappability | Partial | Maps to a custom SwiftUI row / Material 3 DropdownMenuItem. Disabled should be an orthogonal prop, not a type value. |
| C5 | Interaction State Coverage | Needs Fix | No pressed or focused variants. Selected state relies on label color alone — no checkmark or background fill. |
| C6 | Asset & Icon Quality | Needs Fix | Country variant uses a raster PNG flag, not a vector instance. Peso sign is a vector via Peso Sign - Proxima. |
| C7 | Code Connect Linkability | Pending | No CLI mappings registered yet; blocked by C2, C5, C6. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Blocked | Rename disabeld → disabled before registering |
| Asset quality | Blocked | Replace raster PH flag with vector flag slot |
| State coverage | Blocked | Pressed / focused variants missing |
| Native component file | Pending | EBDropdownItem.swift / EBDropdownItem.kt not yet created |
5 type values × 2 selected values=10 theoretical slots, but disabeld only ships with selected=false, giving 9 actual variants.
| type | selected | Node ID | Notes |
|---|---|---|---|
| text | false | 23:199454 | Neutral label |
| text | true | 23:199456 | Brand label |
| text with tag | false | 883:29328 | Trailing Badge instance |
| text with tag | true | 883:30370 | Brand label + Badge |
| amount | false | 23:199458 | Peso sign + "X,XXX.XX" |
| amount | true | 23:199465 | Brand peso + brand label |
| country | false | 23:199472 | Raster flag + "Philippines +63" |
| country | true | 23:199476 | Brand "Philippines +63" |
| disabeld Typo | false | 883:30386 | Soft fill + muted label |
disabeld misspelled — ships into the generated TS type. Rename to disabled. Openselected on/off and a pseudo-disabled content type. Touch/keyboard feedback unmodeled. Opentype value — collides with content types (text / amount / country). Should be an orthogonal disabled axis. OpenA generic dropdown component with a select-style trigger and an overlay item list. 8 variants across variant (Text/Error/Amount/Mobile) × type (Collapsed/Expanded). Text and Error variants use a standard label + select field trigger. Amount adds a peso sign prefix. Mobile bundles a country code selector with a phone number input field. Optional subtext slot for helper/error messages.
selected uses yes/no instead of true/false (C2). Amount variant Peso Sign uses BOOLEAN_OPERATION (C6).Contexts are illustrative. Final screens will reference actual GCash patterns.
Toggle variant and type to see the dropdown update in real time.
selected uses yes/no string instead of true/false boolean (C2). Property type is a generic name — could conflict with platform keywords. Layer dropdowncontainer inconsistent with kebab-case convention.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Collapsed (Default) | Yes | Yes | type=Collapsed | Gray #D7E0EF border, white bg. Chevron down. |
| Expanded (Active) | Yes | Yes | type=Expanded | Blue #005CE5 border, chevron up. Dropdown list overlay with shadow. |
| Error | Yes | Yes | variant=Error | Red border — weak #F4C7C9 (collapsed), strong #D61B2C (expanded). |
| Disabled | No | No | — | Not defined. Required for form accessibility. |
| Pressed | No | No | — | Not defined. Touch feedback expected on mobile. |
- DropdownItem
selectedusesyes/nostrings. Should betrue/falsefor direct SwiftBool/ KotlinBooleanmapping in Code Connect. C2 · Variant & Property Naming - No disabled state. Form dropdowns must support a non-interactive state for accessibility and conditional form flows (e.g. "Country" disabled until "Region" is picked). C5 · Interaction State Coverage
- No pressed state. Touch feedback is expected on both platforms — iOS highlight, Android ripple. Currently consumers improvise this. C5 · Interaction State Coverage
- Peso Sign uses a
shape_fullBOOLEAN_OPERATION. Not a clean vector path — renders inconsistently across SVG export and native platforms. Flatten to a single path. C6 · Asset & Icon Quality - Code Connect mappings not registered. Blocked until the boolean and state issues above are resolved. C7 · Code Connect Linkability
- Add a
Disabledstate to the variant matrix. Required for form accessibility and conditional logic — without it, consumers hack opacity filters on the parent frame. State - Rename
typetoisExpanded. Avoids platform keyword conflicts (typeis reserved in many languages) and aligns with boolean naming convention. Rename - Rename DropdownItem
selectedvalues totrue/false. Direct boolean mapping eliminates the string-to-bool conversion layer when wiring Code Connect. Rename - Extract Mobile variant into a dedicated
CountryCodeDropdown. It bundles too many concerns (dropdown + phone input) for a generic dropdown and forces the base component to carry country-specific logic. Family - Add a selected-visual state to DropdownItem. Checkmark or background highlight makes the current selection unambiguous — today the open menu looks the same whether an item is picked or not. State
8 variants across 2 axes: variant (Text/Error/Amount/Mobile) × type (Collapsed/Expanded). Text and Error share the same trigger structure. Amount adds a peso sign. Mobile adds a label row with info icon, plus a phone input field.
Default text dropdown. Label header, select trigger with placeholder text and chevron, optional subtext. Used for general-purpose list selection.
Error state dropdown with red border. Collapsed uses weak border (#F4C7C9), expanded uses strong border (#D61B2C). Subtext turns red for error messaging.
Amount selection with peso sign prefix. Same trigger structure as Text but with a currency indicator for monetary value selection.
Country code dropdown with phone number input. Bundles a label row (with info icon), a select field for country code, and a Labeled Field for phone number entry. Product-specific to GCash mobile number flows.
Trigger field and dropdown list colors. Border color is the primary state indicator. Error variant uses distinct border tokens.
| Role | Token | DEFAULT | ACTIVE | ERROR (collapsed) | ERROR (expanded) |
|---|---|---|---|---|---|
| Trigger border | selected-field/color/{state}/border | #D7E0EF | #005CE5 | #F4C7C9 | #D61B2C |
| Trigger bg | selected-field/color/{state}/bg | #FFFFFF | #FFFFFF | #FFFFFF | #FFFFFF |
| Placeholder | selected-field/color/{state}/placeholder | #90A8D0 | #90A8D0 | #90A8D0 | #90A8D0 |
| Chevron icon | selected-field/color/{state}/icon | #005CE5 | #005CE5 | #005CE5 | #005CE5 |
| Peso sign (Amount) | selected-field/color/{state}/icon-currency | #183462 | #183462 | – | – |
| Header label | formgroup-header/color/label | #0A2757 | #0A2757 | #0A2757 | #0A2757 |
| Item label | dropdown-item/color/default/label | – | #0A2757 | – | #0A2757 |
| Item border | dropdown-item/color/default/border | – | #E5EBF4 | – | #E5EBF4 |
| Dropdown bg | bg/color-bg-main | – | #FFFFFF | – | #FFFFFF |
| Subtext | text/color-text-weak | #445C85 | #445C85 | – | – |
| Error subtext | border/color-border-destructive | – | – | #D61B2C | #D61B2C |
| Property | Value |
|---|---|
| Trigger height | 46px |
| Corner radius | 6px (radius-2) |
| Trigger padding | 6px top, 8px bottom, 12px horizontal |
| Chevron size | 32 × 32 |
| Peso sign size (Amount) | 15 × 15 |
| Item padding | 16px vertical, 12px left, 16px right |
| Dropdown corner radius | 6px |
| Dropdown shadow | 0 6px 12px rgba(2,14,34,0.16) |
| Header padding bottom | 8px |
| Layer | Text Style | Font | Size | Tracking | Line-height |
|---|---|---|---|---|---|
| Header label | Primary/Label/Light/Small | HeyMeow Rnd Semibold | 14px | 0.25px | 14px |
| Trigger placeholder | Primary/Label/Light/Small | HeyMeow Rnd Semibold | 14px | 0.25px | 14px |
| Dropdown item | Primary/Label/Light/Large | HeyMeow Rnd Semibold | 18px | 0.25px | 18px |
| Subtext | — | BarkAda Semibold | 12px | 0px | 18px |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:dropdown:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.dropdown.* // Compose
Package not yet published. These are the planned distribution paths.
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| variant=Text | EBDropdown(label:items:) | EBDropdown(label, items) | Default text selection |
| variant=Error | .ebError(true) | isError=true | Validation failed state |
| variant=Amount | .ebStyle(.amount) | style=EBDropdownStyle.Amount | Shows peso sign prefix |
| variant=Mobile | .ebStyle(.mobile) | style=EBDropdownStyle.Mobile | Country code + phone input |
| type=Collapsed | — | — | Default closed state (managed internally) |
| type=Expanded | isPresented: Binding<Bool> | expanded: Boolean | Dropdown list visible |
| subtext (boolean) | helperText: String? | helperText: String? | Optional helper/error text |
EBDropdown("Category", selection: $category) { ForEach(categories) { item in Text(item.name) } }
EBDropdown( label = "Category", items = categories, selectedItem = selected, onItemSelected = { selected = it } )
EBDropdown("Category", selection: $category) { ForEach(categories) { item in Text(item.name) } } .ebError(true) .ebHelperText("Please select a category")
EBDropdown( label = "Category", items = categories, selectedItem = selected, onItemSelected = { selected = it }, isError = true, helperText = "Please select a category" )
EBDropdown("Amount", selection: $amount) { ForEach(amounts) { item in Text(item.formatted) } } .ebStyle(.amount)
EBDropdown( label = "Amount", items = amounts, selectedItem = selected, onItemSelected = { selected = it }, style = EBDropdownStyle.Amount )
| Requirement | iOS | Android |
|---|---|---|
| Minimum touch target | 44 × 44 pt | 48 × 48 dp |
| Accessibility label | .accessibilityLabel("Select category") | contentDescription |
| Role | .accessibilityAddTraits(.isButton) | semantics { role=Role.DropdownList } |
| Expanded state | VoiceOver: "collapsed" / "expanded" | TalkBack: announce expansion state |
| Item selection | .accessibilityValue(selectedItem) | semantics { stateDescription } |
Do
Use Dropdown for selecting from a predefined list of options. Label the trigger clearly so users know what they're selecting.
Don't
Use Dropdown for free-text entry — use Input Field instead. Dropdown is for constrained selection only.
Do
Show error state with helper text below the field explaining the validation issue.
Don't
Use the Mobile variant for generic dropdown needs — it bundles phone-specific UI that adds complexity without value.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic names: label, container, text-container, peso-sign, Chevron Up/Down. Minor: dropdowncontainer missing separator. |
| C2 | Variant & Property Naming | Partial | DropdownItem selected uses yes/no instead of true/false. type is a generic property name. |
| C3 | Token Coverage | Ready | All colors bound to design tokens. Space, radius, typography, and elevation tokens all present. |
| C4 | Native Mappability | Partial | Text/Error/Amount map to Menu (iOS) / ExposedDropdownMenuBox (Android). Mobile variant needs custom composition. |
| C5 | Interaction State Coverage | Needs Fix | Missing disabled and pressed states. Only Collapsed, Expanded, and Error defined. |
| C6 | Asset & Icon Quality | Partial | Chevrons are vector instances. Amount variant Peso Sign uses BOOLEAN_OPERATION (shape_full). |
| C7 | Code Connect Linkability | Pending | No CLI mappings registered yet. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Partial | DropdownItem selected needs boolean rename; type is generic |
| Asset quality | Partial | Peso Sign BOOLEAN_OPERATION in Amount variant |
| State coverage | Blocked | Missing disabled/pressed states blocks complete mapping |
| Native component file | Pending | EBDropdown.swift / EBDropdown.kt not yet created |
4 variant values × 2 type values (Collapsed/Expanded). subtext boolean toggleable on all variants.
| variant | type | Node ID |
|---|---|---|
| Text | Collapsed | 18482:31966 |
| Text | Expanded | 18482:31960 |
| Error | Collapsed | 18482:31955 |
| Error | Expanded | 18482:31949 |
| Amount | Collapsed | 18482:31944 |
| Amount | Expanded | 18482:31938 |
| Mobile | Collapsed | 18482:31911 |
| Mobile | Expanded | 18482:31924 |
selected=yes/no instead of true/false. Incompatible with Swift Bool and Kotlin Boolean for Code Connect mapping. Openshape_full is a BOOLEAN_OPERATION, not a clean vector path. May render inconsistently on native platforms. OpenA tappable list-row card used for product/service entries — 360×146 fixed container with a leading icon slot, a content block (blurb + tag pill, heading, 1–2 description lines, optional bottom badge), and a trailing chevron. 12 Figma variants across iconSize (64/52/46/40/32/24) × state (Default / skeleton), plus 6 orthogonal boolean slot toggles: hasBlurb, hasTag, hasSubtitle, has2ndDescription, hasBadge, hasChevron.
iconSize to semantic values (XL / L / M / S). Replace the icon placeholder with a swappable Avatar / Icon slot via instance swap. Replace the raster chevron with a vector. Add a pressed state — this is clearly a tappable row but only Default + skeleton are modeled today.Generic Card stacks vertically into a scrolling list — product catalogs, service menus, transaction history detail screens. Icon size tightens as the density of the list increases.
Type your own copy to test flexibility. Toggle slots individually to see how the card responds when pieces are hidden. Flip state between Default and skeleton to see the loading pattern.
iconSize values is a lot. Tag uses Badge (good) and bottom pill uses Badge (good) — composition is correct where it happens.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | state=Default | Normal row — all content visible, chevron shown when hasChevron. |
| Skeleton (loading) | Yes | Yes | state=skeleton | Loading pattern — gray rounded placeholders where each content slot would render. Kudos for shipping this as a first-class variant. |
| Pressed | Missing | Missing | Not built | Tappable row (has chevron) — needs a pressed state with background tint for tap feedback. |
| Disabled | Missing | Missing | Not built | For temporarily-unavailable services (e.g. maintenance). Typically dimmed label + muted icon. |
iconSizeuses 6 numeric values.64 / 52 / 46 / 40 / 32 / 24is too granular — consumers can't tell when to pick 46 vs 40. Collapse to 3–4 semantic sizes (XL / L / M / S) with fixed pixel values behind the scenes. C2 · Variant & Property Naming- Icon slot is a hardcoded placeholder circle. Not an Avatar or Icon instance — blocks swappable composition. Designers can't drop in a brand icon, illustration, or Avatar without detaching. C6 · Asset & Icon Quality
- Chevron is a raster image. Ships as a PNG (
shape_full) rather than a vector glyph — blurs at large render sizes and blocks token-based tinting. C6 · Asset & Icon Quality - No pressed / disabled states. Has a chevron, so clearly tappable — but only Default + skeleton states are built. Pressed tint and disabled appearance are standard row affordances. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked until iconSize collapse + icon-slot adoption land. C7 · Code Connect Linkability
- Collapse
iconSizeto semantic sizes.xl (64) / l (52) / m (40) / s (32)— drops 6 numeric values to 4 meaningful ones. (46 and 24 can be retired or mapped to the nearest semantic size.) Matches how Avatar sizes are named elsewhere. Rename - Replace the icon placeholder with a Figma Slot. Accept an Avatar instance (for person/brand rows) or an Icon instance (for service rows). Native maps to
@ViewBuilder(SwiftUI) or@Composableslot (Compose) via Code Connect. Slot - Convert the chevron to a vector. Replace the raster
shape_fullwith a vector path — token-bindable color and crisp at any scale. Asset - Add pressed + disabled states. Pressed: subtle bg tint on the whole row. Disabled: muted label + icon opacity. Rows are tappable and need both. State
- Document the skeleton pattern as a DS convention. Generic Card's skeleton variant is exactly the pattern other row/card components should follow. Call it out in the guidelines so the same loading treatment spreads consistently. Docs
- See sibling:Generic Transaction Card. Similar "card row" primitive — the two could share a common schema once both are cleaned up. Family
18482:35807Full-featured row: icon + blurb with tag, heading, 2 description lines, bottom badge, chevron.
360 × 14616 24 16 1224241 px64 × 6432 × 32| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface | card-list/bg | #FFFFFF |
| Bottom border | card-list/border | #E5EBF4 |
| Heading | card-list/label-header | #0A2757 |
| Blurb | card-list/label-blurb | #005CE5 @ 90% |
| Description label | card-list/label | #90A8D0 |
| Description value | card-list/description | #445C85 |
| Icon placeholder | (not tokenized) | #C2C6CF |
HeyMeow Rnd Bold · 18 / 23 · +0.25HeyMeow Rnd Bold · 14 / 14 · +0.25BarkAda Semibold · 12 / 18 · +0HeyMeow Rnd Bold · 12 / 12 · +0.5HeyMeow Rnd Bold · 12 / 12 · +0.5Badge · Negative · HeavyBadge · Information · Light18482:35832The loading pattern for the card. Every content slot becomes a rounded rectangle placeholder in neutral gray. Use while awaiting data.
#E0E6F26 (rects) · 50% (icon circle).package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBGenericCard
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
iconSize: 64 | 52 | 46 | 40 | 32 | 24 | size: xl | l | m | s | .controlSize(.large) etc. | size: EBGenericCardSize |
| (drawn circle) | leadingMedia: Avatar | Icon (slot) | leadingMedia: EBLeadingMedia? | leadingMedia: @Composable (() -> Unit)? |
| (hardcoded text) | heading: String | heading: String | heading: String |
hasBlurb | blurb?: String | blurb: String? | blurb: String? |
hasTag | tag?: Badge (instance) | tag: EBBadge? | tag: @Composable (() -> Unit)? |
hasSubtitle | (derived: shown if blurb or tag present) | — | — |
| (hardcoded "Description goes here") | descriptions: [LabelValue] | descriptions: [EBLabelValue] | descriptions: List<EBLabelValue> |
has2ndDescription | (derived: up to N rows rendered) | — | — |
hasBadge | badge?: Badge (instance) | badge: EBBadge? | badge: @Composable (() -> Unit)? |
hasChevron | showChevron: Bool=true | showChevron: Bool=true | showChevron: Boolean=true |
state: Default | skeleton | loading: Bool | loading: Bool | loading: Boolean |
| (not modeled) | onTap?: () -> Void | onTap: (() -> Void)? | onClick: (() -> Unit)? |
ios/Components/GenericCard/EBGenericCard.swiftandroid/components/genericcard/EBGenericCard.kt
// Full-featured row EBGenericCard( leadingMedia: .icon(Image("send-money")), blurb: "PROMO", tag: EBBadge("New", state: .negative), heading: "Send Money Abroad", descriptions: [ .init(label: "Fee:", value: "Free for first transfer"), .init(label: "Delivery:", value: "Same day") ], badge: EBBadge("Recommended", state: .information, level: .light) ) { onRowTap() } .controlSize(.large) // Loading state EBGenericCard.skeleton(size: .large)
// Full-featured row EBGenericCard( leadingMedia = { EBIcon(EBIcons.SendMoney) }, blurb = "PROMO", tag = { EBBadge("New", state = EBBadgeState.Negative) }, heading = "Send Money Abroad", descriptions = listOf( EBLabelValue("Fee:", "Free for first transfer"), EBLabelValue("Delivery:", "Same day") ), badge = { EBBadge("Recommended", state = EBBadgeState.Information, level = EBBadgeLevel.Light) }, size = EBGenericCardSize.Large, onClick = { onRowTap() } ) // Loading state EBGenericCard.Skeleton(size = EBGenericCardSize.Large)
| Requirement | iOS | Android |
|---|---|---|
| Row as a button | Whole row wrapped in Button with combined accessibilityLabel (heading + blurb + tag). | Modifier.clickable { onTap() }.semantics(mergeDescendants=true) on the row. |
| Combined announcement | "Send Money Abroad, PROMO, New, Free for first transfer, Same day, Recommended" — VoiceOver reads top-to-bottom. | Same reading order — TalkBack follows composition. |
| Loading state | Announce "Loading" once on mount; suppress per-placeholder announcements. | Apply contentDescription="Loading" to the skeleton container. |
| Min touch target | 146 px row height ≫ 44 pt ✓ | 146 dp ≫ 48 dp ✓ |
- Use for navigational lists of services, products, or detail entries.
- Stack vertically into a scrollable list — don't put side-by-side.
- Show the skeleton variant while data loads — never blank rows.
- Match
iconSizeto list density (XL for top-level, S for dense sub-lists).
- Don't use for transaction history rows — use Generic Transaction Card.
- Don't overload the blurb + tag combo — one accent is enough per row.
- Don't hide the chevron if the row is tappable — the affordance is the point.
- Don't mix iconSizes in the same list.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Clean container / content / chevron hierarchy. Tag and bottom pill are Badge instances. |
| C2 | Variant & Property Naming | Needs Refinement | 6 numeric iconSize values — collapse to semantic scale. |
| C3 | Token Coverage | Ready | All colors bound to main/card-list/color/* + Badge tokens. |
| C4 | Native Mappability | Needs Refinement | Maps cleanly to a row composable once icon slot + chevron are fixed. |
| C5 | Interaction State Coverage | Needs Refinement | Default + skeleton built. Missing pressed + disabled for a tappable row. |
| C6 | Asset & Icon Quality | Needs Refinement | Icon placeholder isn't an instance; chevron is a raster. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until iconSize rename and icon slot adoption land. |
iconSize (6) × state (2)=12 variants. The 6 boolean slot props (hasBlurb, hasTag, hasSubtitle, has2ndDescription, hasBadge, hasChevron) toggle content independently and don't multiply the variant count.
| iconSize | Default node | Skeleton node | Dimensions |
|---|---|---|---|
| 64 | 18482:35807 | 18482:35832 | 360 × 146 |
| 52 | 18482:35843 | 18482:35868 | 360 × 146 |
| 46 | 18482:35879 | 18482:35904 | 360 × 146 |
| 40 | 18482:35915 | 18482:35940 | 360 × 146 |
| 32 | 18482:35951 | 18482:35976 | 360 × 146 |
| 24 | 18482:35987 | 18482:36012 | 360 × 146 |
A compact transaction-history row with a label, date/time metadata (plus an optional badge tag), and trailing content (amount, menu button, or a reference number). 5 variants under a single type enum: default, more information, with avatar, no amount, skeleton loader. Used inside GCash Jr. transaction lists and Detailed Transaction History screens.
type values are visually distinct layouts, not variants of the same pattern. Replace the enum with slot-based composition (leadingMedia?, badge?, trailing=amount | menu | reference, loading). Same fix pattern as Alert's Full Width. Also align heading weight with Generic Card (both should use Bold, not Semibold).Transaction-history rows stack vertically in the Activity / Transactions screen. Different rows use different variants depending on the context (recipient avatar, reference number, action menu).
Flip type to swap between the 5 layouts. Content inputs stress-test flexibility. Notice how different types expose different slots — today Figma encodes this as a single enum; the proposal is to expose slots directly.
type enum. Heading uses Semibold 600 while Generic Card uses Bold 700 — inconsistent across the card family.type and live with its fixed slot composition. A slot-based API would let them mix freely (e.g. avatar + reference).| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | type=default | Label + badge + date + amount. The baseline transaction row. |
| With avatar | Yes | Yes | type=with avatar | Adds a 32 × 32 Avatar at the leading edge. Used for person-to-person transactions. |
| More information | Yes | Yes | type=more information | Replaces the badge with an overflow menu button (⋯). Used when a row has context-menu actions. |
| No amount | Yes | Yes | type=no amount | Swaps the amount for a trailing badge; swaps date for a reference number. Used for confirmations without monetary value. |
| Skeleton loader | Yes | Yes | type=skeleton loader | Loading placeholder pattern. Worth documenting alongside Generic Card's skeleton as a DS-wide convention. |
| Pressed | Missing | Missing | Not built | Rows typically drill into transaction detail — need pressed tint for tap feedback. |
typeenum hides 5 structurally different layouts. Same anti-pattern as Alert'sFull Widthboolean.default/more information/with avatar/no amountare not variants of one pattern — they're four different slot compositions. Replace with a slot-based API (leadingMedia,badge,trailing,loading). C1 · Layer Structure & Namingno amountandmore informationdescribe absence, not role. Value names should describe what the variant IS, not what it lacks.no amountbecomes "swap amount for a trailing badge";more informationbecomes "show action menu." Once slot-based, the enum disappears entirely. C2 · Variant & Property Naming- Heading uses Semibold (600) while Generic Card uses Bold (700). Inconsistent title weight across the card family. Standardize — either both Bold or both Semibold. C2 · Variant & Property Naming
- No pressed / disabled states. Transaction rows drill into a detail screen on tap — need pressed tint. Also disabled state for pending/failed transactions that can't be reopened. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked on the slot restructure. C7 · Code Connect Linkability
- Replace
typewith slot-based props.leadingMedia?: Avatar | Icon | none,badge?: Badge,metadata: String,trailing: amount | menu | badge | reference,loading: Bool. Eliminates the 5-type union and lets consumers compose any valid row without editing the master. Property - Align heading weight with Generic Card. Pick one (Bold 700 is more common across the DS) and apply it to both components. Card family should read as one system, not two dialects. Family
- Promote skeleton to a cross-family convention. Generic Card + Generic Transaction Card both ship skeletons — document the pattern (
#E0E6F2fill, rounded rect placeholders, no spinner) as the DS loading standard so future card/row primitives follow the same treatment. Docs - Add pressed + disabled states. Pressed: subtle bg tint across the whole row. Disabled: muted label + amount opacity. State
- Reconcile with Generic Card. The two share ~80 % of the "row with leading / trailing / meta" shape. Consider a shared
EBRowprimitive with variants for "with subtitle" (Generic Card) and "with metadata + amount" (Generic Transaction Card). Family
18482:35754The baseline transaction row. Leading label, mid-row badge + date metadata, trailing amount. 78 px tall.
36016 24 18 22681 px| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface | card-list/bg | #FFFFFF |
| Bottom border | card-list/border | #E5EBF4 |
| Label / Amount | card-list/label-header | #0A2757 |
| Metadata | card-list/label-metadata | #6780A9 |
| Badge bg | badge/information/light/bg | #E5F1FF |
| Badge label | badge/information/light/label | #005CE5 |
HeyMeow Rnd Semibold · 18 / 18 · +0.25HeyMeow Rnd Semibold · 18 / 18 · +0.25BarkAda Semibold · 12 / 18 · +0HeyMeow Rnd Bold · 12 / 12 · +0.5Badge · Information · LightAvatar · dark-initials · 32 px18482:35776Adds a 32 × 32 Avatar at the leading edge (instance-swapped from the Avatar component). Used for person-to-person transactions.
18482:35789Used for non-monetary confirmations (KYC acknowledgments, voucher redemptions). Swaps amount for a trailing badge and the date row for a reference number.
.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBTransactionRow
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
| (hardcoded) | label: String | label: String | label: String |
| (hardcoded) | metadata?: String | metadata: String? | metadata: String? |
type=with avatar | leadingMedia?: Avatar (slot) | leadingMedia: EBAvatar? | leadingMedia: @Composable (() -> Unit)? |
| (drawn) | badge?: Badge (slot) | badge: EBBadge? | badge: @Composable (() -> Unit)? |
type=default/with avatar | trailing=.amount(String) | trailing: EBRowTrailing | trailing: EBRowTrailing |
type=more information | trailing=.menu(() -> Void) | 同上 | 同上 |
type=no amount | trailing=.badge(Badge) + metadata=reference | 同上 | 同上 |
type=skeleton loader | loading: Bool | loading: Bool | loading: Boolean |
| (not modeled) | onTap?: () -> Void | onTap: (() -> Void)? | onClick: (() -> Unit)? |
ios/Components/TransactionRow/EBTransactionRow.swiftandroid/components/transactionrow/EBTransactionRow.kt
// Default — label + badge + date + amount EBTransactionRow( label: "Juan Dela Cruz", badge: EBBadge("Sent", state: .information, level: .light), metadata: "Apr 14, 2026, 10:24 AM", trailing: .amount("PHP 1,500.00") ) { onRowTap() } // With avatar EBTransactionRow( leadingMedia: EBAvatar(initials: "JD"), label: "Juan Dela Cruz", badge: EBBadge("Sent"), metadata: "Apr 14, 2026, 10:24 AM", trailing: .amount("PHP 1,500.00") ) // No amount — trailing badge + reference number EBTransactionRow( label: "KYC Verification", metadata: "Reference No: GC123456789876543", trailing: .badge(EBBadge("Approved", state: .success)) ) // Loading EBTransactionRow.skeleton()
// Default — label + badge + date + amount EBTransactionRow( label = "Juan Dela Cruz", badge = { EBBadge("Sent") }, metadata = "Apr 14, 2026, 10:24 AM", trailing = EBRowTrailing.Amount("PHP 1,500.00"), onClick = { onRowTap() } ) // With avatar EBTransactionRow( leadingMedia = { EBAvatar(initials = "JD") }, label = "Juan Dela Cruz", badge = { EBBadge("Sent") }, metadata = "Apr 14, 2026, 10:24 AM", trailing = EBRowTrailing.Amount("PHP 1,500.00") ) // No amount — trailing badge + reference EBTransactionRow( label = "KYC Verification", metadata = "Reference No: GC123456789876543", trailing = EBRowTrailing.BadgeSlot { EBBadge("Approved") } ) // Loading EBTransactionRow.Skeleton()
| Requirement | iOS | Android |
|---|---|---|
| Row as button | Whole row in Button with combined label (person + amount + date). | Modifier.clickable { onTap() }.semantics(mergeDescendants=true). |
| Currency announcement | "Juan Dela Cruz, Sent, 1,500 pesos, April 14 10:24 AM" — use localized currency formatter, not raw "PHP 1,500.00". | Same — announce via contentDescription with currency formatter applied. |
| Reference number | Spell out long reference numbers for clarity: "GC 1 2 3 4..." — avoid run-together digits. | Same. |
| Loading | Announce "Loading transactions" once on mount. | contentDescription="Loading" on skeleton container. |
- Use for transaction history lists (Activity, Send / Receive logs).
- Use avatar variant for person-to-person transactions.
- Use skeleton on load — never a blank list.
- Format currency per locale; format dates per user preference.
- Don't mix
typevalues in the same list without reason — it looks unstable. - Don't use for service/catalog rows — use Generic Card.
- Don't omit the date/meta — transaction rows without context are confusing.
- Don't hide the row in a pressed state — the row still exists, it's just visually pressed.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | type enum hides 5 layouts — restructure to slot-based. |
| C2 | Variant & Property Naming | Rework | Absence-based value names; heading weight inconsistent with Generic Card. |
| C3 | Token Coverage | Ready | Colors bound to main/card-list/color/*. |
| C4 | Native Mappability | Needs Refinement | Maps cleanly once slots replace the type enum. |
| C5 | Interaction State Coverage | Needs Refinement | No pressed / disabled. Skeleton ✓. |
| C6 | Asset & Icon Quality | Ready | Avatar + Badge composed correctly as instances. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on restructure. |
type is a single enum with 5 values, each a structurally different layout.
| # | Node | type | Layout | Dimensions |
|---|---|---|---|---|
| 1 | 18482:35754 | default | label · badge · date · amount | 360 × 78 |
| 2 | 18482:35765 | more information | label · date · amount · menu (⋯) | 360 × 76 |
| 3 | 18482:35776 | with avatar | avatar · label · badge · date · amount | 360 × 84 |
| 4 | 18482:35789 | no amount | label · reference · trailing badge | 360 × 76 |
| 5 | 18482:35797 | skeleton loader | loading placeholders | 360 × 81 |
type enum (5 layouts) with slot-based composition. Align heading weight with Generic Card. Add pressed state. OpenFull Width. Split into leadingMedia, badge, trailing, loading. Openno amount / more information describe what's missing. Semantic slot names replace them. OpenA centered label banner sitting at the top of a screen, modal, or feature. 4 variants from type=dark | light × description=yes | no. The dark variant uses a brand-blue surface with white text; the light variant uses a default surface with dark text. Used for page titles in modal sheets, settings sub-screens, and feature banners.
type=dark | light with surface=brand | default — the current name describes appearance, not semantic intent. See the Header family restructure for the full plan.Page Banner sits at the top of a screen, modal, or feature card — centered, taking full width, setting the title of the surface below it.
type=dark|light names appearance rather than semantic tone.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default (brand) | Yes | Yes | type=dark | White text on brand-blue surface. |
| Default (default) | Yes | Yes | type=light | Dark text on default surface. |
| Pressed | N/A | N/A | — | Banner is informational — no pressed state. |
| Disabled | N/A | N/A | — | Not interactive. |
- Rename to Page Banner. "Header" prefix shared with 3 structurally different components. C1 · Layer Structure & Naming
- Rename
type=dark|lighttosurface=brand|default. Current name describes appearance; convention is to name by semantic surface role. C2 · Variant & Property Naming - No Code Connect mapping.C7 · Code Connect Linkability
- Rename to Page Banner. Frees the "Header" namespace and signals semantic role clearly. Rename
- Use
surface=brand | defaultinstead oftype=dark | light. Matches how other DS components (Button appearance, Badge tone) describe surface variants. Property - Optional: expose
alignmentprop (center | leading) for teams that want a left-aligned variant without creating a new component. Property - See siblings:Header, Header - With Logo, Header - Transaction. Family
18430:2859White title on brand-blue surface. The "hero" variant — used for primary feature banners.
darkyes| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface | surface/brand | #005CE5 |
| Title | text/on-brand | #FFFFFF |
| Description | text/on-brand-muted | #C8D8F5 |
Fill104 (hug)space/space-24 space/space-16space/space-4Heading/L · BarkAda 18/24Body/S · 12/16center18430:2865Dark title on default surface. Used for modal sheet titles and subdued banners.
lightyes| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface | surface/default | #FFFFFF |
| Title | text/primary | #0A2757 |
| Description | text/secondary | #90A8D0 |
.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBPageBanner
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
| (implicit) | title: String | title: String | title: String |
description: boolean | description?: String | description: String? | description: String? |
type: dark | light | surface: brand | default | .ebSurface(.brand) modifier | surface=EBSurface.Brand |
ios/Components/PageBanner/EBPageBanner.swiftandroid/components/pagebanner/EBPageBanner.kt
// Brand surface with description EBPageBanner( title: "Send Money", description: "To any GCash user" ) .ebSurface(.brand) // Default surface, title only EBPageBanner(title: "Settings") .ebSurface(.default)
// Brand surface with description EBPageBanner( title = "Send Money", description = "To any GCash user", surface = EBSurface.Brand ) // Default surface, title only EBPageBanner(title = "Settings", surface = EBSurface.Default)
| Requirement | iOS | Android |
|---|---|---|
| Heading trait | .accessibilityAddTraits(.isHeader) on the title. | Modifier.semantics { heading() } on the title. |
| Contrast | Brand surface: white on #005CE5=8.5:1 ✓. Default surface: #0A2757 on #FFFFFF=15.4:1 ✓. | Same contrast ratios apply. |
| Screen reader order | Title → Description. VoiceOver reads in DOM order. | Same — TalkBack follows composition order. |
- Use as a page title banner at the top of a screen, modal, or feature card.
- Pick
brandsurface for hero/promotional banners;defaultfor subdued titles. - Apply the heading a11y trait so screen readers can navigate by structure.
- Don't use for in-screen section titles — use Section Header.
- Don't use for app-level top bars — use Title Bar.
- Don't stack multiple banners on one screen.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Rename to Page Banner. |
| C2 | Variant & Property Naming | Rework | Rename type=dark|light → surface=brand|default. |
| C3 | Token Coverage | Ready | Surface and text tokens bound. |
| C4 | Native Mappability | Ready | Maps cleanly to a EBPageBanner view/composable. |
| C5 | Interaction State Coverage | N/A | Static — no interactive states. |
| C6 | Asset & Icon Quality | N/A | No assets. |
| C7 | Code Connect Linkability | Not Mapped | Trivial once renamed. |
type × description=4 variants.
| # | Node | type | description | Dimensions |
|---|---|---|---|---|
| 1 | 18430:2859 | dark | yes | 360 × 104 |
| 2 | 18430:2865 | light | yes | 360 × 104 |
| 3 | 18430:2871 | dark | no | 360 × 84 |
| 4 | 18430:2873 | light | no | 360 × 84 |
type=dark|light for surface=brand|default. OpenA brand-surface hero block with an avatar, title ("Add Label Here"), a horizontal divider, an optional inline email: address@example.com row, and 1–2 lines of description. Used on transaction detail and recipient profile screens. Structurally this is a card hero, not a header — its anatomy (avatar + title + divider + label-value + body) matches the hero patterns seen in profile cards and detail screens across other DS. 2 variants: email=yes | no.
Detail Hero appears at the top of transaction detail screens and recipient profile cards — introducing the person or transaction below the app bar.
email boolean can't extend to other metadata rows (phone, MCC, reference number). Needs a flexible rows slot.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default (no email) | Yes | Yes | email=no | Avatar + title + divider + description. |
| With email | Yes | Yes | email=yes | Adds an inline email: value row above the description. |
| Pressed / Disabled | N/A | N/A | — | Static — no interactive states. |
- Misfiled as a header. Anatomy is a card hero (avatar + title + divider + label-value + description). Rename to Detail Hero and move out of the Header family. C1 · Layer Structure & Naming
- Avatar is a placeholder, not an instance. Should accept a real Avatar instance (status ring, initials, image — all of which Avatar already supports). C4 · Native Mappability
- Label-value row is hardcoded to "email:". A detail hero needs flexible metadata rows (phone, reference #, MCC, transaction ID). Replace the boolean with a rows slot or accept a Labeled Field instance. C2 · Variant & Property Naming
- Pressed state on avatar is not defined — if the avatar is tappable (opens profile, edits photo), it needs state coverage. C5 · Interaction State Coverage
- No Code Connect mapping. Blocked on the Detail Hero rehome decision. C7 · Code Connect Linkability
- Rename to Detail Hero and move out of the Header family. Rehome next to Visual Popup / card primitives. Rename
- Replace the avatar placeholder with a real Avatar instance. Avatar already supports image, initials, and status ring — no reason to re-draw it here. Composition
- Replace
email=yes|nowith a flexible rows slot.metadata: [LabelValuePair]lets consumers supply any number of rows (email, phone, reference, MCC). Each row could be a small inline-label component or a Labeled Field variant. Property - Expose
surface=brand | default(same pattern as Page Banner). Detail heroes on settings screens may want the default surface. Property - See siblings:Header, Header - Centered, Header - With Logo. Family
18430:2906The minimal variant — avatar + title + divider + description. Used when the profile/transaction has no extra metadata to show.
no| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface | surface/brand | #005CE5 |
| Title | text/on-brand | #FFFFFF |
| Description | text/on-brand-muted | #C8D8F5 |
| Divider | border/on-brand-subtle | #2B6BEA |
| Avatar fill | (placeholder) | #C8CDD5 |
Fill220 (hug)space/space-2448 × 48space/space-12Heading/L · BarkAda 20/26Body/S · 13/1818430:2898Adds an inline email: value row between the divider and the description. Used on recipient profile cards.
yesmetadata: [LabelValuePair]muted label · strong value.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBDetailHero
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
| (implicit) | title: String | title: String | title: String |
| (placeholder) | avatar: Avatar (instance) | avatar: EBAvatar | avatar: @Composable (() -> Unit) |
email: boolean | metadata: [LabelValuePair] | metadata: [EBLabelValue] | metadata: List<EBLabelValue> |
| (implicit) | description?: String | description: String? | description: String? |
| (implicit brand) | surface: brand | default | .ebSurface(.brand) modifier | surface=EBSurface.Brand |
ios/Components/DetailHero/EBDetailHero.swiftandroid/components/detailhero/EBDetailHero.kt
// Recipient profile card EBDetailHero( title: "Juan Dela Cruz", avatar: EBAvatar(initials: "JD"), metadata: [ .init(label: "email", value: "juan@gmail.com"), .init(label: "phone", value: "0917 123 4567") ], description: "Recipient since 2023" ) .ebSurface(.brand) // Transaction detail EBDetailHero( title: "₱1,500.00", avatar: EBAvatar(icon: .arrowUp), metadata: [.init(label: "ref #", value: "2026-0414-00917")], description: "Sent to GCash account" )
// Recipient profile card EBDetailHero( title = "Juan Dela Cruz", avatar = { EBAvatar(initials = "JD") }, metadata = listOf( EBLabelValue("email", "juan@gmail.com"), EBLabelValue("phone", "0917 123 4567") ), description = "Recipient since 2023", surface = EBSurface.Brand )
| Requirement | iOS | Android |
|---|---|---|
| Heading trait | Apply to the title line. | Modifier.semantics { heading() } on the title. |
| Avatar a11y | If decorative, mark .accessibilityHidden(true). If identifying, label with person's name. | Same — contentDescription empty when decorative, or person's name when identifying. |
| Label-value pairs | Group each pair with .accessibilityElement(children: .combine) so VoiceOver reads "email, juan@gmail.com" as one utterance. | Use Modifier.semantics(mergeDescendants=true) per row. |
| Contrast on brand surface | White text on #005CE5=8.5:1 ✓. Muted #C8D8F5 on #005CE5=2.1:1 — fails AA body text. Use only for secondary labels ≥14pt bold. | Same ratios — reserve muted color for label text, not body copy. |
- Use as a hero card at the top of detail/profile screens — after the app bar, before the content.
- Supply real Avatar instances (image, initials, icon) — not placeholder circles.
- Use the metadata rows for short label-value pairs only (email, phone, reference).
- Don't confuse with a header or app bar — this is a card hero.
- Don't pack more than 2–3 metadata rows — use a list below the hero instead.
- Don't put interactive controls inside the hero — keep actions in the bar above or the content below.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | "Header - Transaction" misfiled. Rename to Detail Hero. |
| C2 | Variant & Property Naming | Rework | email=yes|no should become metadata: [LabelValuePair]. |
| C3 | Token Coverage | Ready | Surface, title, description tokens bound. |
| C4 | Native Mappability | Rework | Avatar should be a real instance; metadata should be structured, not drawn. |
| C5 | Interaction State Coverage | Needs Refinement | Avatar pressed state not defined — needed if tappable. |
| C6 | Asset & Icon Quality | Needs Refinement | Avatar is a drawn placeholder, not a vector Avatar instance. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on rehome + avatar-instance decisions. |
| # | Node | Dimensions | |
|---|---|---|---|
| 1 | 18430:2906 | no | 360 × 220 |
| 2 | 18430:2898 | yes | 360 × 191 |
email=yes|no with a flexible metadata: [LabelValuePair] slot. OpenA brand app bar displaying the GCash logo on a brand-blue surface. 2 variants: logo=dark | light (dark logo on lighter bg vs. light logo on brand bg). Used on splash, login, onboarding, and home top bars. Structurally a top app bar — the same role Title Bar already covers with a title.
23:175148) but swaps the title text for a logo. Rather than maintain two app-bar components, add a leading=title | logo slot to Title Bar and retire this file. One app bar primitive, two behaviours. See Header family restructure for the full plan.Brand app bar appears on splash, login, onboarding, and home screens — anywhere the brand identity should lead before page-specific navigation takes over.
| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Dark logo | Yes | Yes | logo=dark | Dark GCash mark on lighter brand surface. |
| Light logo | Yes | Yes | logo=light | Light GCash mark on brand surface. |
| Pressed | N/A | N/A | — | Not interactive. |
- Consolidate into Title Bar. Duplicates app-bar scope. Add
leading=title | logoslot to Title Bar and retire this file. C4 · Native Mappability - "Header" prefix conflates with 3 structurally different components. If kept as a separate component, rename to Brand App Bar. C1 · Layer Structure & Naming
logo=dark|lightnames the asset, not the surface. Should betheme=dark | lightor tied to the surrounding surface token. C2 · Variant & Property Naming- No Code Connect mapping.C7 · Code Connect Linkability
- Merge into Title Bar. Add a
leadingslot to Title Bar that accepts either a title string or a logo instance. One app bar component, two behaviours. Eliminates "which component do I use?" friction. Family - If kept separate, rename to Brand App Bar. Frees the "Header" namespace and signals the narrow branded-surface role. Rename
- Use a single Logo component as the visual — don't bake dark/light as a Header-level property. The Logo component should own its theme variants and be instance-swapped inside the app bar. Composition
- See siblings:Header, Header - Centered, Header - Transaction, Title Bar (merge target). Family
18430:2876Dark GCash mark on brand surface. Used where extra contrast is needed or on lighter brand tints.
dark| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface | surface/brand | #005CE5 |
| Logo mark | brand/logo-dark | #0A2757 |
Fill88 (hug)~120 × 32center18430:2887Light GCash mark on brand surface. The default variant for most branded screens.
light| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface | surface/brand | #005CE5 |
| Logo mark | brand/logo-light | #FFFFFF |
.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBTitleBar import com.gcash.designsystem.components.EBLogo
| Figma (today) | Figma (proposed — Title Bar) | SwiftUI | Compose |
|---|---|---|---|
| (standalone component) | Title Bar / leading=logo | EBTitleBar { EBLogo() } | EBTitleBar(leading={ EBLogo() }) |
logo: dark | light | EBLogo.theme: dark | light | EBLogo(theme: .light) | EBLogo(theme=EBLogoTheme.Light) |
ios/Components/TitleBar/EBTitleBar.swift(existing — add leading slot)ios/Components/Logo/EBLogo.swift(new — theme variants)android/components/titlebar/EBTitleBar.kt(existing)android/components/logo/EBLogo.kt(new)
// Brand app bar via Title Bar with logo leading slot EBTitleBar(leading: .logo(EBLogo(theme: .light))) .ebSurface(.brand) // Or with an optional trailing action EBTitleBar( leading: .logo(EBLogo()), trailing: .icon(Image(systemName: "bell"), action: onNotifs) ) .ebSurface(.brand)
// Brand app bar via Title Bar with logo leading slot EBTitleBar( leading = { EBLogo(theme = EBLogoTheme.Light) }, surface = EBSurface.Brand ) // Or with an optional trailing action EBTitleBar( leading = { EBLogo() }, trailing = { IconButton(onClick = onNotifs) { Icon(EBIcons.Bell, null) } }, surface = EBSurface.Brand )
| Requirement | iOS | Android |
|---|---|---|
| Logo a11y label | Logo carries .accessibilityLabel("GCash") — identify as brand, not decorative. | Logo carries contentDescription="GCash". |
| Not a button | The logo is not tappable by default; no button trait. | No clickable modifier unless a screen wires one up. |
| Heading role | App bar owns heading trait on its overall container. | Same — semantics { heading() } on the bar. |
- Use on splash, login, onboarding, and home-screen top bars.
- Compose via Title Bar + Logo slot (proposed model).
- Give the logo a meaningful a11y label — "GCash", not "logo".
- Don't maintain a separate "Header - With Logo" component once Title Bar gains the leading slot.
- Don't stack a logo + title in the same bar — pick one.
- Don't resize the logo outside its approved brand dimensions.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | "Header" prefix conflates with 3 other components. If kept, rename to Brand App Bar. |
| C2 | Variant & Property Naming | Needs Refinement | logo=dark|light names the asset, not the surface. Prefer theme. |
| C3 | Token Coverage | Ready | Surface bound to brand token. |
| C4 | Native Mappability | Rework | Duplicates Title Bar scope. Merge rather than create a second app bar primitive. |
| C5 | Interaction State Coverage | N/A | Static bar. |
| C6 | Asset & Icon Quality | Ready | Logo is a vector instance. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on merge-vs-rename decision. |
| # | Node | logo | Dimensions |
|---|---|---|---|
| 1 | 18430:2876 | dark | 360 × 88 |
| 2 | 18430:2887 | light | 360 × 88 |
leading=logo slot. Retire this file. Openlogo=dark|light to theme=dark|light. OpenIn-screen section-title block (e.g. "Recent transactions · View all"). 16 variants generated from 8 independent boolean properties: preamble, description, icon, left illustration, right illustration, link, edit, counter. Not a navigation header or app bar — see Title Bar for that. Part of a 4-component "Header" family that needs restructuring.
preamble, leadingMedia, trailing), and the sibling "Header - *" components either renamed by role or merged into existing primitives. See the Family Restructure section below for the full plan.Section headers sit above grouped content — a list of transactions, a set of services, a carousel of offers — to label the section and optionally expose a trailing action.
Toggle the slots to see how the current 16-variant matrix responds. Note how many combinations are theoretically possible but not built — that's the boolean-model tax.
| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | 16 variants | The only state today — no pressed/disabled/focused. |
| Trailing action pressed | Via slot | Via slot | Not modeled | Link, edit, counter should be real Button/Link instances that carry their own pressed state. |
| Disabled | N/A | N/A | Not modeled | Section headers are informational — no disabled variant needed. |
| Focused (a11y) | N/A | N/A | — | Focus lives on the trailing action, not the header itself. |
The Header family spans four components that share a visual prefix but solve four different problems. Before cleaning this component, align on the family shape.
| Today | Proposed | Semantic role | Action |
|---|---|---|---|
| Header (this component) · 16 variants | Section Header | In-screen section title | Rename + collapse 8 booleans → 3 props. |
| Header - Centered · 4 variants | Page Banner | Centered page/modal title | Rename. Swap type=dark|light for surface=brand|default. |
| Header - With Logo · 2 variants | Merge intoTitle Bar | Brand app bar | Add leading=title | logo slot to Title Bar. Retire this file. |
| Header - Transaction · 2 variants | Detail Hero | Card hero — not a header | Move out of Header family. Rehome near Visual Popup / card patterns. |
From 8 booleans to 3 props — covering the same design space with zero invalid combinations.
// Proposed API — replaces 8 boolean props with 3 EBSectionHeader( preamble: "RECENT", title: "Transactions", description: "Your activity this month", leadingMedia: .icon(Image(systemName: "chart.bar")), trailing: .link("View All", action: onViewAll) )
// Proposed API — slot-based with typed trailing enum EBSectionHeader( title = "Transactions", preamble = "RECENT", description = "Your activity this month", leadingMedia = { Icon(EBIcons.Chart, contentDescription = null) }, trailing = EBHeaderTrailing.Link("View All", onClick = onViewAll) )
- Family-wide naming conflict. "Header" prefix conflates 4 roles (section header, page banner, brand bar, detail hero). Rename each by role. C1 · Layer Structure & Naming
- 8 boolean props, 256 theoretical combos, 16 built. The boolean model implies combinations that don't exist. Collapse
icon+left illustrationintoleadingMedia, andright illustration+link+edit+counterintotrailing. C2 · Variant & Property Naming - Trailing actions aren't real components. Link ("View All"), Edit, and Counter are drawn in-place rather than accepting Button / Badge / Link instances. Breaks composition and blocks state handling (pressed, disabled). C4 · Native Mappability
- No pressed/disabled states on the actionable slots. Natively, those slots need full state coverage — at minimum pressed + disabled. C5 · Interaction State Coverage
- No Code Connect mappings. Trivial once slots are enumerated. C7 · Code Connect Linkability
- Rename to "Section Header". Unambiguously signals in-screen section title; frees "Header" namespace. Rename
- Collapse to 3 props —
preamble?,leadingMedia?: icon | illustration,trailing?: illustration | link | edit | counter— plus the requiredtitle. Eliminates invalid combos, drops variant count from 16 to ~6 canonical patterns. Property - Promote actionable slots to real components. "View All" becomes a Text Button instance. "Edit" becomes an Icon Button. "Counter" becomes a Badge instance. Each carries its own pressed/disabled states. Composition
- Move "Header - Transaction" out of the family. It's a card hero, not a header. Rename to Detail Hero and rehome near Visual Popup. Family
- Merge "Header - With Logo" into Title Bar. Add
leading=title | logoslot to Title Bar instead of maintaining a second app-bar component. Family - See siblings:Header - Centered, Header - With Logo, Header - Transaction. Restructure is a family-wide decision. Family
18430:2932The simplest variant — a bare title. This is the baseline the other 15 variants layer slots onto.
nononoHeading/L — BarkAda 18/24text/primary · #0A2757Fill58 (hug)0space/space-4| ROLE | TOKEN | VALUE |
|---|---|---|
| Title | text/primary | #0A2757 |
| Preamble | text/brand | #005CE5 |
| Description | text/secondary | #90A8D0 |
| Link label | text/brand | #005CE5 |
18430:2920All three text slots filled. This is the canonical "announce a section" pattern.
yesyesnoLabel/S caps · 12/16 · text/brandHeading/L · 18/24 · text/primaryBody/S · 12/16 · text/secondary18430:2984Title on the left, "View All" link on the right. Common list-section pattern.
Proposed: the "View All" text should be an instance of EBTextButton — not a drawn text layer — so it inherits pressed/disabled states automatically.
18430:2989Title left, pencil icon + "Edit details" link right. Used on profile/settings sections.
Proposed: "Edit details" should be an EBTextButton with leadingIcon: Image("pencil"). That's already a supported pattern in Button — no new component needed.
18430:2996Title left, numeric counter pill right. Used on inbox/notifications.
Proposed: counter should be an instance of EBBadge (numeric variant). Badge already handles count + overflow ("99+") correctly.
.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBSectionHeader
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
preamble: boolean | preamble?: String | preamble: String? | preamble: String? |
| (implicit) | title: String | title: String (required) | title: String (required) |
description: boolean | description?: String | description: String? | description: String? |
icon + left illustration | leadingMedia?: icon | illustration | leadingMedia: EBLeadingMedia? | leadingMedia: @Composable (() -> Unit)? |
right illustration + link + edit + counter | trailing?: illustration | link | edit | counter | trailing: EBHeaderTrailing? | trailing: EBHeaderTrailing? |
ios/Components/SectionHeader/EBSectionHeader.swiftandroid/components/sectionheader/EBSectionHeader.kt
// 1 · Title only EBSectionHeader(title: "Services") // 2 · Title + View All link EBSectionHeader( title: "Transactions", trailing: .link("View All", action: onViewAll) ) // 3 · Full stack with leading icon EBSectionHeader( preamble: "RECENT", title: "Activity", description: "Past 30 days", leadingMedia: .icon(Image(systemName: "chart.bar")) ) // 4 · Title + counter badge EBSectionHeader( title: "Notifications", trailing: .counter(12) )
// 1 · Title only EBSectionHeader(title = "Services") // 2 · Title + View All link EBSectionHeader( title = "Transactions", trailing = EBHeaderTrailing.Link("View All", onClick = onViewAll) ) // 3 · Full stack with leading icon EBSectionHeader( preamble = "RECENT", title = "Activity", description = "Past 30 days", leadingMedia = { Icon(EBIcons.Chart, contentDescription = null) } ) // 4 · Title + counter badge EBSectionHeader( title = "Notifications", trailing = EBHeaderTrailing.Counter(12) )
| Requirement | iOS | Android |
|---|---|---|
| Heading trait | Apply .accessibilityAddTraits(.isHeader) to the title. | Apply Modifier.semantics { heading() } to the title text. |
| Trailing action label | Link/Edit/Counter must each carry their own accessibility label. Counter should announce count ("12 unread"). | Same — each trailing slot owns its own semantics. |
| Minimum touch target | Trailing interactive element must be ≥44×44pt. | Trailing interactive element must be ≥48×48dp. |
| Reading order | Preamble → Title → Description → Trailing. VoiceOver follows DOM order. | Same reading order — TalkBack follows composition order. |
- Use for in-screen section titles above grouped content.
- Keep the title short — ideally one line, max two.
- When you need a trailing action, use a real Button/Link/Badge instance.
- Apply the heading a11y trait so screen readers can navigate by headings.
- Don't use Section Header as a page title or app bar — use Title Bar / Page Banner.
- Don't stack multiple leading media (icon + illustration both filled).
- Don't draw "View All" as static text — use Text Button so it handles pressed/disabled.
- Don't combine every slot on one instance — the 256-combo temptation is the anti-pattern this component is built on.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | "Header" prefix shared with 3 structurally different components. Rename to Section Header. |
| C2 | Variant & Property Naming | Rework | 8 booleans → 3 props (preamble, leadingMedia, trailing). Drops 16 variants to ~6 canonical patterns. |
| C3 | Token Coverage | Ready | Typography and color bound to DS tokens. |
| C4 | Native Mappability | Needs Refinement | Maps to a simple EBSectionHeader view/composable once slots collapse. Trailing actions should be real Button/Badge instances, not drawn. |
| C5 | Interaction State Coverage | Needs Refinement | Header itself is static; trailing actions inherit Button/Link state coverage once they become instances. |
| C6 | Asset & Icon Quality | Needs Refinement | Confirm leading/trailing "illustration" slots accept vector instances (Avatar / Icon / custom). Placeholder circle suggests unverified. |
| C7 | Code Connect Linkability | Not Mapped | Cannot map until property model collapses and trailing slots resolve to real components. |
Today: 8 independent boolean properties — preamble, description, icon, left illustration, right illustration, link, edit, counter. 2⁸=256 theoretical combos, only 16 built — most combinations are either invalid or unsupported.
| Group | Count | Slots enabled |
|---|---|---|
| Text-only | 4 | preamble × description permutations |
| With right icon (top-aligned) | 4 | preamble × description × icon |
| With leading illustration | 2 | description × left illustration |
| With trailing illustration | 2 | description × right illustration |
| With link (View All) | 2 | description × link |
| With edit | 1 | edit only |
| With counter | 1 | counter only |
View full breakdown (16 rows)
| # | Node | Preamble | Description | Icon | L-Illus | R-Illus | Link | Edit | Counter |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 18430:2920 | ✓ | ✓ | ||||||
| 2 | 18430:2925 | ✓ | |||||||
| 3 | 18430:2929 | ✓ | |||||||
| 4 | 18430:2932 | ||||||||
| 5 | 18430:2934 | ✓ | ✓ | ✓ | |||||
| 6 | 18430:2941 | ✓ | ✓ | ||||||
| 7 | 18430:2947 | ✓ | ✓ | ||||||
| 8 | 18430:2952 | ✓ | |||||||
| 9 | 18430:2956 | ✓ | ✓ | ||||||
| 10 | 18430:2962 | ✓ | |||||||
| 11 | 18430:2967 | ✓ | ✓ | ||||||
| 12 | 18430:2973 | ✓ | |||||||
| 13 | 18430:2978 | ✓ | ✓ | ||||||
| 14 | 18430:2984 | ✓ | |||||||
| 15 | 18430:2989 | ✓ | |||||||
| 16 | 18430:2996 | ✓ |
preamble, leadingMedia, trailing). OpenA 336-wide horizontal voucher tile composed of a full-width 336×144 raster hero image (with the "GrabFood" wordmark and two stacked discount Badge instances — "10% off" and "35% off" — both anchored top-right) plus a content block with a single row of 4 hardcoded status badges ("Limited", "Expiring", "Hot", "Discounted"), a hardcoded title ("Grab Food"), description, price (PHP 100.00 with PHP 150.00 strikethrough), and validity period. 1 symbol, no variants, 6 boolean toggles. All text and badge labels are frozen placeholder strings.
5119:1635), and Voucher Card Horizontal (5119:1786) are three parallel records of the same component. Merge into a single Voucher Card with orientation: vertical | horizontal, state: default | limited | expiring | used | expired (borrowed from Voucher Card Horizontal — the canonical sibling since it ships the state axis), an image Slot that accepts Voucher Asset instances, a composable badges: [Badge] array, and text slots for title / description / price / original price / validity. Drop the 6 booleans in favor of real string properties.Single symbol, 336 × 265. A 336×144 raster hero image sits on top of a 336-wide content block. The image carries two duplicate discount Badges stacked at the same top-right anchor ("10% off" and "35% off"). The content block holds a single row of 4 hardcoded badges, a hardcoded title, description, price (with strikethrough original), and validity period. Every text string is placeholder; every badge label is frozen.
main/vouchers/color/default/*. But it ships two stacked discount Badge instances ("10% off" + "35% off") at the same anchor, assuming one is invisible — nothing in the schema picks between them.state axis (Default/Limited/Expiring/Used/Expired); Horizontal Voucher ships none. Property shape diverges across the family (6 booleans here vs 8 in Vertical vs 2 booleans + 4-state enum in Voucher Card Horizontal).| Aspect | iOS | Android | Figma | Notes |
|---|---|---|---|---|
| Hero image | Image Slot: image: () -> some View | image: @Composable () -> Unit | asset boolean (raster frozen) | Currently a 336×144 raster with the "GrabFood" wordmark baked in. Should accept any Voucher Asset variant. |
| Discount amount | discount: String? on image slot | Same | Two stacked Badges ("10% off" + "35% off") | Two Badge instances at the same anchor. Should be a single discount string property on the Voucher Asset, not two stacked layers. |
| Title | title: String | title: String | header boolean (string hardcoded) | "Grab Good" frozen in the symbol. Boolean only toggles visibility. |
| Description | description: String? | Same | description boolean (string hardcoded) | "This is the description of the voucher." frozen. |
| Price / original | price: String, originalPrice: String? | Same | amount boolean (strings hardcoded) | "PHP 100.00" and "PHP 150.00" frozen; one boolean toggles both. |
| Validity | validity: String? | Same | validityPeriod boolean (string hardcoded) | "Validity: Dec 25 2022 - Jan 5 2023" frozen. |
| Status badges | badges: [EBBadge] | badges: List<EBBadge> | badges boolean (row of 4 hardcoded) | Single row of 4 fixed badge labels ("Limited", "Expiring", "Hot", "Discounted"). Row-level visibility only. |
| State | state: .default | .limited | .expiring | .used | .expired | Same enum | Not modelled | Absent entirely. Voucher Card Horizontal has it; Horizontal Voucher does not. |
| Tap target | Entire card as Button with PlainButtonStyle | Card with onClick + ripple | Not modelled | Vouchers are always tappable; current symbol has no pressed/disabled states. |
- Voucher content tokens exist. Background (
text/color-text-inverse=white card surface), title (main/vouchers/color/default/label-title), description (label-description), amount (label-amount), strikethrough amount (rendered as#90a8d0, matcheslabel-amount-original), and metadata (label-metadata) are all bound to the voucher component's variable collection. C3 · Token Coverage - Card elevation is tokenised. The card uses
app/shadow/shadow-low(0px/0px/4px,elevation/app/shadow-low/colorrgba(2,14,34,0.06)). No hardcoded shadow values on the parent frame. C3 · Token Coverage
- Three parallel components for one concept. Horizontal Voucher, Vertical Voucher (
5119:1635), and Voucher Card Horizontal (5119:1786) share the same anatomy — voucher image + title + description + price + validity + status badges — but ship as three separate components with divergent property shapes. This is a family-level consolidation, not a single-component fix. C4 · Native Mappability - No state axis. Voucher Card Horizontal ships Default / Limited / Expiring / Used / Expired as a proper state variant that drives background, label colors, and badge treatment. Horizontal Voucher has no state concept — a used or expired horizontal voucher cannot be rendered in greyed-out treatment. C5 · Interaction State Coverage
- All text content is hardcoded placeholder. Title "Grab Food", description "This is the description of the voucher.", price "PHP 100.00", original price "PHP 150.00", and validity "Validity: Dec 25 2022 - Jan 5 2023" are all frozen strings inside the symbol. Booleans toggle visibility but not content. Consumers cannot render a real voucher without detaching. C2 · Variant & Property Naming
- Two stacked discount Badges at the same anchor. The Voucher Asset image frame nests two Badge instances (
I5121:4534;6983:110671"10% off" andI5121:4534;6983:110685"35% off") both absolutely positioned at the top-right. Only one is ever intended to be visible, but no property selects between them — both render on top of each other in the raw symbol. C1 · Layer Structure & Naming - Discount amount is baked into the image frame. The "35% off" / "10% off" label is a hardcoded Badge text inside the Voucher Asset, not a property on the parent Horizontal Voucher. A voucher offering "50% off" or "BUY1 TAKE1" cannot be rendered. Should be a
discount: String?property on the image slot. C2 · Variant & Property Naming - Status badges are row-level, not array-level. The
badgesboolean toggles a single fixed row of 4 hardcoded Badge instances ("Limited" + "Expiring" + "Hot" + "Discounted") on or off. A real voucher with one "Limited" badge and nothing else cannot be rendered. Badges should be a composable array, not a fixed row. C2 · Variant & Property Naming - Hero image is a raster photograph with burned-in partner wordmark.
Paste Image Here/imgPasteImageHereis a 336×144 raster asset with the "GrabFood" wordmark burned into the pixels. Partner branding and image content are frozen. Should be an Image Slot that accepts any Voucher Asset variant (GrabFood, Globe, Smart, Shopee, etc.). C6 · Asset & Icon Quality - No native component maps to this shape. 6 booleans with hardcoded content do not map to any reasonable native API. A proper
EBVoucherCardtakes title, price, validity, badges array, and image as parameters — not six visibility toggles over frozen strings. Code Connect has no 1:1 target. C4 · Native Mappability - Code Connect cannot link a 6-boolean symbol with frozen strings. Even if a mapping existed, swapping the "title" string, the hero image, or the badge labels would require detaching the component. Linkability requires real string/array properties and an image Slot first. C7 · Code Connect Linkability
- Merge the three voucher cards into a single Voucher Card component. Horizontal Voucher + Vertical Voucher + Voucher Card Horizontal become one component with
orientation: vertical | horizontal(swaps the layout axis) andstate: default | limited | expiring | used | expired(borrowed from Voucher Card Horizontal — the canonical sibling since it already ships the state axis). Target shape: 2 orientations × 5 states=10 variants instead of three separate components with divergent schemas. Family - Promote every text string to a property. Add
title: String,description: String,price: String,originalPrice: String?,validity: String?. Retire theheader/amount/description/validityPeriodbooleans — visibility falls out of whether the string is empty. Property - Replace the stacked discount Badges with one
discountstring on the image slot. Drop the duplicated "10% off" and "35% off" Badge instances inside the Voucher Asset frame. Exposediscount: String?on the voucher-image Slot so any discount value ("10% off", "50% off", "BUY1 TAKE1") can be rendered without editing the symbol. Property - Adopt a Figma Slot for the hero image. Replace the raster
Paste Image Herefill with a Slot that accepts a Voucher Asset instance (or any partner illustration component). The discount overlay and the raster wordmark both move into the swapped-in asset, not into the parent Voucher. Slot - Replace the fixed 4-badge row with a composable badges array. Drop the single
badgesboolean. Expose a badges Slot that accepts 0..n Badge instances and wraps when it runs out of width. Consumers choose which badges apply ("Limited" alone, "Hot" + "Discounted", "New" + "Featured", etc.). Slot - Add the state axis missing from Horizontal Voucher. Used and Expired vouchers render in greyed-out treatment with muted labels and a dimmed hero image — a pattern Voucher Card Horizontal already ships. Port the same 5-state treatment to the unified Voucher Card. State
- Remove the dead
10% offBadge layer. The image frame carries bothI5121:4534;6983:110671("10% off") andI5121:4534;6983:110685("35% off") at identical coordinates; after thediscountproperty above is introduced, only one Badge instance should remain, with its text bound to the new property. Composition - Document that Voucher Card is the tap target. Vouchers are always tappable entry points to the voucher detail screen. The unified component should ship a pressed/focused state on the card frame; the handoff is an
onTapclosure, not an internal CTA button. Docs
Native models the voucher family as a single EBVoucherCard with orientation, state, text properties, a composable badges array, and a Voucher Asset image parameter. The current Horizontal Voucher's 6 booleans do not have a 1:1 native analog; consumers write real strings and state enums instead.
// Proposed API — unified Voucher Card (horizontal orientation) EBVoucherCard( orientation: .horizontal, state: .limited, title: "Grab Food", description: "Get a discount on your next GrabFood order.", price: "PHP 100.00", originalPrice: "PHP 150.00", validity: "Dec 25 2022 - Jan 5 2023", badges: [ EBBadge("Limited", style: .informationHeavy), EBBadge("Expiring", style: .negativeHeavy) ] ) { EBVoucherImageFrame(discount: "35% off") { Image("voucher-grabfood").resizable().scaledToFill() } } .onTapGesture { // navigate to voucher details } // Used/expired treatment EBVoucherCard(orientation: .horizontal, state: .used, title: "Grab Food", price: "PHP 100.00") { EBVoucherImageFrame { Image("voucher-grabfood").resizable().scaledToFill() } }
// Proposed API — unified Voucher Card (horizontal orientation) EBVoucherCard( orientation = EBVoucherOrientation.Horizontal, state = EBVoucherState.Limited, title = "Grab Food", description = "Get a discount on your next GrabFood order.", price = "PHP 100.00", originalPrice = "PHP 150.00", validity = "Dec 25 2022 - Jan 5 2023", badges = listOf( EBBadge("Limited", style = EBBadgeStyle.InformationHeavy), EBBadge("Expiring", style = EBBadgeStyle.NegativeHeavy) ), onClick = { /* navigate to voucher details */ }, image = { EBVoucherImageFrame(discount = "35% off") { Image( painter = painterResource(R.drawable.voucher_grabfood), contentDescription = null, contentScale = ContentScale.Crop ) } } )
The current 6 booleans do not map cleanly to native. The table below shows the target shape after the family consolidation — each row captures what the proposed EBVoucherCard replaces from the current Horizontal Voucher.
| Current Figma | Proposed Figma | SwiftUI | Compose | Notes |
|---|---|---|---|---|
| — | orientation | orientation: EBVoucherOrientation | orientation=EBVoucherOrientation | vertical | horizontal — collapses 3 components into 1 |
| — | state | state: EBVoucherState | state=EBVoucherState | default | limited | expiring | used | expired (port from Voucher Card Horizontal) |
asset (boolean, raster frozen) | Image Slot | trailing closure | image: @Composable () -> Unit | Accepts any EBVoucherImageFrame instance; partner wordmark lives inside the asset, not the parent |
| "10% off" + "35% off" Badges | discount on Voucher Asset | EBVoucherImageFrame(discount: "35% off") | EBVoucherImageFrame(discount="35% off") | One string on the image-frame child, not two stacked Badge layers on the parent |
header (boolean, string frozen) | title (string) | title: String | title: String | Visibility=whether string is empty |
description (boolean, string frozen) | description (string) | description: String? | Same | Same |
amount (boolean, PHP 100 / PHP 150 frozen) | price + originalPrice | price: String, originalPrice: String? | Same | Two strings, strikethrough applied to originalPrice |
validityPeriod (boolean, string frozen) | validity (string) | validity: String? | Same | Same |
badges (boolean, row of 4 hardcoded) | badges Slot | badges: [EBBadge] | badges: List<EBBadge> | Composable array, wraps on overflow |
| — | — | onTap: () -> Void | onClick: () -> Unit | Card is the tap target |
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Two discount Badge instances ("10% off" + "35% off") stacked at the same anchor inside the Voucher Asset frame. Text layers are unnamed. Paste Image Here is a generic placeholder layer name. |
| C2 | Variant & Property Naming | Rework | 6 booleans where most should be strings (title, price, validity) or a Slot (badges, image). badges boolean toggles a fixed row of 4 hardcoded badges. All content is frozen placeholder. |
| C3 | Token Coverage | Ready | Title, description, amount, strikethrough amount, and metadata are all bound to main/vouchers/color/default/*. Card elevation uses app/shadow/shadow-low. Typography uses named text styles (Primary/Multi-line Label/Base, Primary/Label/Small, Secondary/Default/Caption, Secondary/Default/Fine). |
| C4 | Native Mappability | Rework | Parallel to 2 other voucher components with divergent schemas. Native has one EBVoucherCard, not three. 6 booleans with frozen strings and a raster hero have no native analog. |
| C5 | Interaction State Coverage | Rework | No state axis at all. Voucher Card Horizontal ships Default/Limited/Expiring/Used/Expired; Horizontal Voucher has none. No pressed/focused/disabled on the card frame either. |
| C6 | Asset & Icon Quality | Rework | Hero image is a raster photograph with the "GrabFood" wordmark burned into the pixels. Discount amount is baked into stacked Badge instances, not a property. |
| C7 | Code Connect Linkability | Rework | Cannot map. Frozen strings, row-level badge toggle, stacked discount badges, and raster hero do not have 1:1 native parameters. Linkability requires the family consolidation first. |
Single symbol, no variant axes declared. All configurability is through 6 boolean property toggles on the lone instance.
| Node ID | Name | Dimensions | Property toggles |
|---|---|---|---|
5121:4533 | Horizontal Voucher | 336 × 265 (with all properties on) | amount, asset, badges, description, header, validityPeriod — all boolean, all default true |
Nested instances (two discount badges stacked at the same anchor; row of four status badges with hardcoded labels):
| Node ID | Layer | Kind | Dimensions |
|---|---|---|---|
5121:4534 | Voucher Asset | Image frame (raster + 2 stacked discount badges) | 336 × 144 |
I5121:4534;6983:110671 | Voucher Asset > Badge "10% off" | Badge instance (brand/heavy) | auto |
I5121:4534;6983:110685 | Voucher Asset > Badge "35% off" | Badge instance (brand/heavy) | auto |
5121:4537 | badges > Badge "Limited" | Badge instance (information/heavy) | auto |
5121:4538 | badges > Badge "Expiring" | Badge instance (negative/heavy) | auto |
5121:4539 | badges > Badge "Hot" | Badge instance (destructive) | auto |
5121:4540 | badges > Badge "Discounted" | Badge instance (brand/heavy) | auto |
5119:1635) and Voucher Card Horizontal (5119:1786). OpenVoucher Card with orientation + state axes, text slots (title, description, price, originalPrice, validity), a badges array, and a voucher image Slot. Target: 2 × 5=10 variants instead of 3 divergent components. OpenA label-value receipt row used as building block inside transaction screens, generic transaction cards, confirmation modals, and list items. Label on the leading edge, value/badge/link on the trailing edge, optional description or text link below. 5 variants under one type enum: Default, with Clipboard, with Badge, with Description, with Text Link. Not a control — it is a layout pattern with opinionated typography and token bindings.
Full Width and Generic Transaction Card's type. Replace the enum with orthogonal booleans (hasCopy, hasDescription, hasTextLink) plus a unified trailing slot so Badge can be instance-swapped instead of drawn inline. The component itself belongs — its four semantic color tokens (label, label-value, description, label-link) give it enough DS opinion to ship as EBInlineText, but behind a cleaner schema.Inline Text is a composition primitive. You'll find stacks of it inside Generic Transaction Card's detail modal, Send Money confirmation screens, receipt summaries, and fee-breakdown list items. Rarely used standalone.
Flip type to swap the trailing-slot composition. Notice how the 5 values aren't variations of one pattern — they produce structurally different rows. The proposal replaces this enum with orthogonal slots.
type enum conflates two axes (trailing-slot content and sub-row content). with Description and with Text Link describe the second row; with Clipboard and with Badge describe the first. Merging them into one enum forces consumers to pick incompatible combinations.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | type=Default | Label leading, value trailing. The baseline receipt row. |
| With Clipboard | Yes | Yes | type=with Clipboard | Value + 24 × 24 copy icon. Tapping the icon copies the value to clipboard. |
| With Badge | Yes | Yes | type=with Badge | Replaces the value with a trailing badge pill. Used for voucher / discount selection. |
| With Description | Yes | Yes | type=with Description | Adds a second row below the label with secondary caption text. |
| With Text Link | Yes | Yes | type=with Text Link | Second row adds a trailing text link (CTA) next to the description. |
| Pressed (copy icon) | Missing | Missing | Not built | Copy icon has no pressed state or success toast hook — users get no feedback when the tap lands. |
typeenum conflates two axes and hides 5 different layouts.with Clipboardandwith Badgedescribe the trailing slot, whilewith Descriptionandwith Text Linkdescribe a second row. As one enum, the combinations "clipboard + description" and "badge + text link" are unrepresentable — even though real screens need them. Split into orthogonal booleans (hasCopy,hasDescription,hasTextLink) with a unifiedtrailingslot. C1 · Layer Structure & Naming- Enum values mix concerns and use "with X" phrasing.
with Clipboard/with Badge/with Description/with Text Linkall read as feature toggles ("with feature X"), which reinforces the anti-pattern of the previous bullet. Under the proposed schema, these become boolean props named by what they ARE, not what they add. C2 · Variant & Property Naming - Badge variant is drawn inline, not instance-swapped. The
with Badgevariant hardcodesmain/badge/information/light/backgroundandmain/badge/information/light/label— duplicating the Badge component's styling rules. If Badge changes its hover state, border, or typography, Inline Text silently drifts. Replace with an instance swap on a trailing slot. C6 · Asset & Icon Quality - Copy icon appears to be drawn inline rather than a DS Icon instance. The
Copynode is a local instance, but its child layers (shape_half,shape_full) suggest a one-off vector rather than a canonical icon swapped from the DS icon library. Confirm the source and, if necessary, replace with a swappableiconprop so consumers can choose clipboard / share / refresh trailing actions. C6 · Asset & Icon Quality - No pressed state on the copy action. The clipboard variant is the only interactive part of Inline Text and it ships with no pressed tint, no focus ring, and no success toast hook. Add a pressed state on the icon and document the success-toast convention. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked on the slot / boolean restructure — mapping today's single
typeenum would bake the anti-pattern into the native API. C7 · Code Connect Linkability
- Replace
typewith orthogonal booleans + a trailing slot. Target schema:label: String,trailing: value(String) | badge(Badge) | custom,hasCopy: Bool,hasDescription: Bool,hasTextLink: Bool,description?: String,ctaLabel?: String. This unlocks valid combinations today's enum forbids (copy + description, badge + link) without adding any net new variants. Property - Instance-swap Badge instead of drawing it inline. Expose a
trailingslot that accepts a Badge instance. Removes the parallel badge-styling source, keeps Inline Text honest as a layout primitive. Composition - Rename the clipboard prop to describe the action.
hasCopyreads better than today'swith Clipboard— "copy" is the action, "clipboard" is the target. Provide anonCopycallback in the native API and document the success-toast convention. Rename - Confirm the copy icon is a DS Icon instance. If it's a local one-off, replace with a swapped DS Icon so the whole family shares one icon source. While at it, expose
trailingIconas a slot so screens can use share / external-link / refresh instead of copy. Slot - Add a pressed state on the copy icon. Subtle tint change (
icon→icon-pressed) plus a documented toast ("Copied to clipboard") on tap. This is the only interactive affordance in Inline Text — it needs feedback. State - Document the stacking recipe. Most real usage is a vertical stack of 3–6 Inline Text rows inside a modal or card (fee breakdowns, transaction details). Add a "Receipt block" recipe page showing the stack spacing (8 px gap, 1 px divider optional) so consumers don't reinvent it. Docs
- Reconcile typography with Generic Transaction Card's metadata. Inline Text's description row uses BarkAda Semibold 12/18. Generic Transaction Card's date metadata uses the same font but a different line-height bucket. Align captions family-wide so receipt text reads consistently. Family
21:138493The baseline row. Leading label, trailing value, 25 px tall, full-width auto-layout.
Default"Label""0.00"| ROLE | TOKEN | VALUE |
|---|---|---|
| Label | inline-text/label | #0A2757 |
| Value | inline-text/label-value | #445C85 |
| Description | inline-text/description | #6780A9 |
| Link | inline-text/label-link | #005CE5 |
| Icon | inline-text/icon | #445C85 |
368 (fill)250 (row) · 5 0 5 0 (value cell)0 (fill + hug)Primary/Label/Light/BaseProxima Soft Semibold16 / 16 / +0.2521:138497Adds a 24 × 24 copy icon to the right of the value. Tapping the icon copies the value to clipboard. Icon uses inline-text/icon token.
with Clipboard"00000000"24 × 2421:138503Replaces the value cell with a Badge pill. Used on voucher / discount rows. Today the badge is drawn inline (information/light hardcoded) rather than instance-swapped from the Badge component.
with Badge"Label"information/light21:138506Adds a second row below the label/value with a description caption. Description uses BarkAda Semibold 12/18.
Secondary/Bold/CaptionBarkAda Semibold12 / 18 / 0224 (auto)368 (fill)21:138512Adds a trailing link (CTA) to the description row. Link uses inline-text/label-link token.
with Text Link"CTA".package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBInlineText
Ship as a thin EBInlineText wrapper — not a docs-only recipe. Although Inline Text is "just a styled HStack," its four semantic color tokens (label, label-value, description, label-link), consistent typography rules, and pressable copy icon make a reusable component worth the ~40 lines of native code. A docs recipe would force every consumer to rebind tokens and redraw the clipboard interaction.
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
| (hardcoded) | label: String | label: String | label: String |
type=Default / with Clipboard | trailing=.value(String) | trailing: EBInlineTextTrailing | trailing: EBInlineTextTrailing |
type=with Badge | trailing=.badge(Badge) (instance) | 同上 | 同上 |
type=with Clipboard | hasCopy: Bool | onCopy: (() -> Void)? | onCopy: (() -> Unit)? |
type=with Description | description?: String | description: String? | description: String? |
type=with Text Link | ctaLabel?: String + onCtaTap? | cta: EBInlineTextCTA? | cta: EBInlineTextCTA? |
| (not modeled) | trailingIcon?: Icon (slot) | trailingIcon: Image? | trailingIcon: @Composable (() -> Unit)? |
ios/Components/InlineText/EBInlineText.swiftandroid/components/inlinetext/EBInlineText.kt
// Default — label + value EBInlineText( label: "Amount", trailing: .value("PHP 1,500.00") ) // With clipboard copy EBInlineText( label: "Reference No", trailing: .value("GC123456789"), onCopy: { showToast("Copied") } ) // With trailing badge (instance-swapped) EBInlineText( label: "Voucher", trailing: .badge(EBBadge("Applied", state: .success)) ) // With description EBInlineText( label: "Service fee", trailing: .value("PHP 10.00"), description: "Non-refundable processing fee" ) // With description + CTA link EBInlineText( label: "Promo code", trailing: .value("GC50OFF"), description: "Saved PHP 50.00 on this order", cta: .init("Change") { openPromoPicker() } )
// Default — label + value EBInlineText( label = "Amount", trailing = EBInlineTextTrailing.Value("PHP 1,500.00") ) // With clipboard copy EBInlineText( label = "Reference No", trailing = EBInlineTextTrailing.Value("GC123456789"), onCopy = { showToast("Copied") } ) // With trailing badge (instance-swapped) EBInlineText( label = "Voucher", trailing = EBInlineTextTrailing.BadgeSlot { EBBadge("Applied") } ) // With description EBInlineText( label = "Service fee", trailing = EBInlineTextTrailing.Value("PHP 10.00"), description = "Non-refundable processing fee" ) // With description + CTA link EBInlineText( label = "Promo code", trailing = EBInlineTextTrailing.Value("GC50OFF"), description = "Saved PHP 50.00 on this order", cta = EBInlineTextCTA(label = "Change", onClick = { openPromoPicker() }) )
| Requirement | iOS | Android |
|---|---|---|
| Row reads as "label, value" | Merge children: .accessibilityElement(children: .combine). Announces "Amount, 1,500 pesos". | Modifier.semantics(mergeDescendants=true) on the row. |
| Copy button | Dedicated Button around the icon with accessibilityLabel: "Copy reference number". Announce success via UIAccessibility.post(.announcement, "Copied"). | IconButton with contentDescription. On click, call view.announceForAccessibility("Copied"). |
| Text link as link | Use Button with accessibilityAddTraits(.isLink). Minimum 44 × 44 touch target. | TextButton or clickable text with role Role.Button. Minimum 48 dp touch target. |
| Currency announcement | Use localized currency formatter on accessibilityValue, not raw "PHP 1,500.00". | Same — announce via contentDescription with currency formatter applied. |
| Dynamic Type / font scaling | Text uses .font(.custom(..., relativeTo: .body)) so it scales with Dynamic Type. | Use sp units for text size; respect system font scale. |
- Use for receipt-style rows inside cards, modals, and confirmation screens.
- Stack multiple rows vertically (8 px gap) to build fee / transaction-detail blocks.
- Use the clipboard variant for reference numbers, wallet IDs, promo codes.
- Use the badge variant when the trailing value is a status, not a number.
- Pair the CTA link with short actions like "Change" or "Edit" — not sentences.
- Don't use for top-level page titles — it's a row primitive.
- Don't omit the label — a value-only row is not Inline Text, it's free text.
- Don't stack with inconsistent types in one block — pick one rhythm.
- Don't reinvent the copy interaction — use the built-in
onCopyhook. - Don't use for primary actions — use Button instead.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | type enum conflates two axes — split into orthogonal booleans + slot. |
| C2 | Variant & Property Naming | Rework | Enum values use "with X" phrasing and describe features, not semantics. |
| C3 | Token Coverage | Ready | All colors bound to main/inline-text/color/*. Spacing + typography tokens intact. |
| C4 | Native Mappability | Needs Refinement | No 1:1 native primitive — it's a styled HStack. Maps cleanly as EBInlineText wrapper once restructured. |
| C5 | Interaction State Coverage | Needs Refinement | No pressed state on the copy icon or text link. |
| C6 | Asset & Icon Quality | Needs Refinement | Badge drawn inline; copy icon appears one-off — confirm + instance-swap both. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on restructure — mapping today's enum would bake the anti-pattern. |
type is a single enum with 5 values — each a structurally different trailing-slot composition.
| # | Node | type | Layout | Dimensions |
|---|---|---|---|---|
| 1 | 21:138493 | Default | label · value | 368 × 25 |
| 2 | 21:138497 | with Clipboard | label · value · copy-icon | 368 × 24 |
| 3 | 21:138503 | with Badge | label · trailing badge | 368 × 24 |
| 4 | 21:138506 | with Description | [label · value] / description | 368 × 38 |
| 5 | 21:138512 | with Text Link | [label · value] / [description · CTA] | 368 × 41 |
type enum (5 layouts) with orthogonal booleans + unified trailing slot. Instance-swap Badge. Add pressed state on copy icon. Opentype values conflate two axes (trailing slot + sub-row). Split into hasCopy, hasDescription, hasTextLink, unified trailing slot. Openwith Badge hardcodes information/light fill + label instead of instance-swapping Badge. Parallel source of truth. Openlabel, label-value, description, label-link) plus icon bound correctly. NotedA basic text input field with border stroke and placeholder text. 8 variants across State (Default/Active/Error/Disabled) × isFilled (Yes/No). Part of the Form Elements group — serves as the base primitive for Labeled Field, Select Field, and Recipient Field.
Contexts are illustrative. Final screens will reference actual GCash patterns.
Toggle state and fill to see the input field update in real time.
isFilled now uses true/false. C1 resolved — text layer renamed from #text-label to #label, consistent with sibling fields.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | State=Default | Gray #D7E0EF border, white bg. |
| Active (Focused) | Yes | Yes | State=Active | Blue #005CE5 border. |
| Error | Yes | Yes | State=Error | Red #D61B2C border. |
| Disabled | Yes | Yes | State=Disabled | #EEF2F9 bg, border hidden. |
isFilledproperty renamed fromYes/Nototrue/false— now maps directly to SwiftBool/ KotlinBooleanC2 Fixed- Text layer renamed from
#text-labelto#label— now consistent with sibling fields C1 Fixed
- Code Connect mappings not registered. Structural work is complete — registration can proceed against the 8-variant
State × isFilledschema. C7 · Code Connect Linkability
- Standardize text layer naming across Form Elements. Input Field now uses
#label; verify the remaining fields (Labeled, Select, Recipient, View Only) follow the same convention so Code Connect label-slot mapping is uniform. Rename - Add a
helperTextslot below the field. Validation messages and hint copy are currently handled outside the component — a first-class slot keeps form anatomy self-contained and matches nativeTextFieldhint affordances. Slot - Add
leadingIcon/trailingIconslots. Labeled Field already has these — extend to Input Field for search, clear, and validation-indicator use cases. Lets the DS cover the full form-element palette without per-screen customization. Slot
8 variants across 2 axes: State (Default/Active/Error/Disabled) × isFilled (Yes/No). All share the same 366×46px container with 6px corner radius.
Idle state with gray border. Text color depends on whether the field has a value.
Focused state with blue border indicating active input.
Validation error state with red border.
Non-interactive state with gray background and hidden border.
All states share the same container structure. Border color is the primary state indicator.
| Role | Token | DEFAULT | ACTIVE | ERROR | DISABLED |
|---|---|---|---|---|---|
| Border | field/border | #D7E0EF | #005CE5 | #D61B2C | hidden |
| Background | field/bg | #FFFFFF | #FFFFFF | #FFFFFF | #EEF2F9 |
| Text (filled) | field/text/filled | #0A2757 | #0A2757 | #0A2757 | #90A8D0 |
| Text (empty) | field/text/placeholder | #90A8D0 | #90A8D0 | #90A8D0 | #C2CFE5 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:form-elements:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.form.* // Compose
Package not yet published. These are the planned distribution paths.
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| isFilled (Yes/No) | text: Binding<String> | value: String | Derived from text content |
| State=Default | — | — | Default idle state |
| State=Active | .focused() | interactionSource | Keyboard active |
| State=Error | .ebError(true) | isError=true | Validation failed |
| State=Disabled | .disabled(true) | enabled=false | Non-interactive |
EBInputField("Placeholder", text: $value)
EBInputField( value = text, onValueChange = { text = it }, placeholder = "Placeholder" )
EBInputField("Placeholder", text: $value) .ebError(true)
EBInputField( value = text, onValueChange = { text = it }, placeholder = "Placeholder", isError = true )
EBInputField("Placeholder", text: $value) .disabled(true)
EBInputField( value = text, onValueChange = { text = it }, placeholder = "Placeholder", enabled = false )
| Requirement | iOS | Android |
|---|---|---|
| Minimum touch target | 44 x 44 pt | 48 x 48 dp |
| Accessibility label | .accessibilityLabel("Input") | contentDescription |
| Error announcement | VoiceOver reads error via .accessibilityValue | TalkBack reads error via semantics { error() } |
Do
Pair with a visible label above or inside the field. Use placeholder text to hint at expected input format.
Don't
Use placeholder text as the only label — it disappears on focus and fails accessibility.
Do
Show error state with a helper text message below the field explaining what needs to be corrected.
Don't
Use Input Field for selection — use Select Field instead. Input Field is for free-text entry only.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Layer renamed to #label, now consistent with sibling fields. |
| C2 | Variant & Property Naming | Ready | isFilled now uses true/false. Boolean naming resolved. |
| C3 | Token Coverage | Partial | Colors appear correct but token binding not verified. |
| C4 | Native Mappability | Ready | Maps to TextField (SwiftUI) / OutlinedTextField (Compose). |
| C5 | Interaction State Coverage | Ready | All 4 states defined: Default, Active, Error, Disabled. |
| C6 | Asset & Icon Quality | Ready | No icons in base Input Field — text only. |
| C7 | Code Connect Linkability | Pending | No CLI mappings registered yet. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Ready | isFilled uses true/false — maps directly to native booleans |
| State coverage | Ready | All 4 states defined |
| Native component file | Pending | EBInputField.swift / EBInputField.kt not yet created |
4 State values × 2 isFilled values.
| State | isFilled | Node ID |
|---|---|---|
| Default | true | 17758:3688 |
| Default | false | 17758:3691 |
| Active | true | 17758:3694 |
| Active | false | 17758:3697 |
| Error | true | 17758:3700 |
| Error | false | 17758:3703 |
| Disabled | true | 17758:3706 |
| Disabled | false | 17758:3709 |
isFilled=Yes/No updated to isFilled=true/false in Figma. Now maps directly to Swift Bool and Kotlin Boolean for Code Connect. Fixed#text-label renamed to #label in Figma. Now consistent with sibling fields (Labeled Field, Select Field, Recipient Field). FixedisFilled=Yes/No instead of true/false. Incompatible with Swift Bool and Kotlin Boolean for Code Connect mapping. OpenAn enhanced form field with leading icon, label + value text container, an XSmall action button, and trailing icon. 8 variants across State (Default/Active/Error/Disabled) × isFilled (true/false). Part of the Form Elements group — extends the base Input Field pattern with icon slots and an embedded action button.
Contexts are illustrative. Final screens will reference actual GCash patterns.
Toggle state and fill to see the labeled field update in real time.
isFilled now uses true/false and property renamed to State. Action button layer renamed to action-button (C1 resolved).trailing-icon uses a rectangle placeholder instead of a swappable icon instance (C6), limiting icon customization at the consumer level.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | State=Default | Gray #D7E0EF border, white bg. |
| Active (Focused) | Yes | Yes | State=Active | Blue #005CE5 border. |
| Error | Yes | Yes | State=Error | Red #D61B2C border. |
| Disabled | Yes | Yes | State=Disabled | #EEF2F9 bg, border hidden. |
isFilledrenamed fromYes/Nototrue/false— now maps directly to SwiftBool/ KotlinBooleanC2 Fixed- Property
staterenamed toState(capitalized) — consistent with sibling Form Elements fields C2 Fixed Button - XSmalllayer renamed toaction-button— now a semantic slot name for flexible consumer customization C1 Fixed- Trailing icon uses shared Placeholder component instance — swappable by design. Internal RECTANGLE is the default visual, replaced by designers when consuming the component C6 Closed
- Code Connect mappings not registered. Structural work is complete — registration can proceed against the 8-variant
State × isFilledschema. C7 · Code Connect Linkability
- Replace hardcoded
Button - XSmallwith anactionslot. Today the trailing action is baked in — consumers can't swap in other button variants or remove it. A named slot makes the action composable via instance swap. Slot - Replace
trailing-iconplaceholder with a swappable icon instance. The currenticon-placeholderRECTANGLE blocks designers from overriding the trailing icon without detaching. Follow the instance-swap pattern used elsewhere in the DS. Slot - Add a
helperTextslot. Validation messages and hint copy are handled externally today — a first-class slot keeps the form anatomy self-contained. Slot
8 variants across 2 axes: State (Default/Active/Error/Disabled) × isFilled (true/false). All share the same 46px height container with 6px corner radius. Each variant includes leading icon, text container (label + value), XSmall action button, and trailing icon.
Idle state with gray border. Leading/trailing icon placeholders, label + value text container, and XSmall action button.
Focused state with blue border indicating active input.
Validation error state with red border.
Non-interactive state with gray background and hidden border.
All states share the same container structure. Border color is the primary state indicator. Text colors depend on isFilled (true/false).
| Role | Token | DEFAULT | ACTIVE | ERROR | DISABLED |
|---|---|---|---|---|---|
| Border | field/border | #D7E0EF | #005CE5 | #D61B2C | hidden |
| Background | field/bg | #FFFFFF | #FFFFFF | #FFFFFF | #EEF2F9 |
| Label (filled) | field/text/label | #0A2757 | #0A2757 | #0A2757 | #90A8D0 |
| Value (filled) | field/text/value | #0A2757 | #0A2757 | #0A2757 | #C2CFE5 |
| Value (empty) | field/text/placeholder | #90A8D0 | #90A8D0 | #90A8D0 | #C2CFE5 |
| Height | 46px |
| Corner radius | 6px |
| Leading icon | 24 × 24 |
| Trailing icon | 24 × 24 |
| Action button | 60 × 24 (radius 99) |
| Font | HeyMeow Rnd |
| Weight | Semibold (600) |
| Size | 14px |
| Letter spacing | 0.25 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:form-elements:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.form.* // Compose
Package not yet published. These are the planned distribution paths.
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| isFilled (true/false) | text: Binding<String> | value: String | Derived from text content |
| State=Default | — | — | Default idle state |
| State=Active | .focused() | interactionSource | Keyboard active |
| State=Error | .ebError(true) | isError=true | Validation failed |
| State=Disabled | .disabled(true) | enabled=false | Non-interactive |
| #label (TEXT) | label: String | label: String | Field label text |
| #value (TEXT) | text: Binding<String> | value: String | Field value text |
| leading-icon | leadingIcon: Image? | leadingIcon: @Composable? | 24×24 icon slot |
| trailing-icon | trailingIcon: Image? | trailingIcon: @Composable? | 24×24 icon slot (C6: rectangle placeholder) |
| action-button | action: EBFieldAction? | action: @Composable? | Semantic slot name (C1 resolved) |
EBLabeledField("Label", text: $value) .leadingIcon(Image("icon-placeholder")) .trailingIcon(Image("chevron-right"))
EBLabeledField( value = text, onValueChange = { text = it }, label = "Label", leadingIcon = { Icon(Icons.Default.Placeholder, null) }, trailingIcon = { Icon(Icons.Default.ChevronRight, null) } )
EBLabeledField("Label", text: $value) .leadingIcon(Image("icon-placeholder")) .ebError(true)
EBLabeledField( value = text, onValueChange = { text = it }, label = "Label", leadingIcon = { Icon(Icons.Default.Placeholder, null) }, isError = true )
EBLabeledField("Label", text: $value) .leadingIcon(Image("icon-placeholder")) .disabled(true)
EBLabeledField( value = text, onValueChange = { text = it }, label = "Label", leadingIcon = { Icon(Icons.Default.Placeholder, null) }, enabled = false )
| Requirement | iOS | Android |
|---|---|---|
| Minimum touch target | 44 x 44 pt | 48 x 48 dp |
| Accessibility label | .accessibilityLabel("Label") | contentDescription |
| Error announcement | VoiceOver reads error via .accessibilityValue | TalkBack reads error via semantics { error() } |
| Action button label | .accessibilityLabel("Action") on button | contentDescription on button |
Do
Use Labeled Field when the input needs a persistent label above the value, a leading icon for context, and an optional action button.
Don't
Use Labeled Field for simple text entry — use Input Field instead. Labeled Field is for complex form rows with icon context.
Do
Provide meaningful icons in the leading and trailing slots — they help users identify the field purpose at a glance.
Don't
Leave the icon placeholders as-is in production — always swap in a contextual icon or hide the slot.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Layer renamed to action-button, now a semantic slot name. |
| C2 | Variant & Property Naming | Ready | isFilled uses true/false. Property renamed to State (capitalized). Both fixes confirmed in Figma. |
| C3 | Token Coverage | Partial | Colors appear correct but token binding not verified. |
| C4 | Native Mappability | Ready | Maps to custom EBLabeledField on both platforms. |
| C5 | Interaction State Coverage | Ready | All 4 states defined: Default, Active, Error, Disabled. |
| C6 | Asset & Icon Quality | Partial | trailing-icon uses icon-placeholder RECTANGLE — not a swappable icon instance. |
| C7 | Code Connect Linkability | Pending | No CLI mappings registered yet. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Ready | isFilled=true/false and State (capitalized) — C2 fixed in Figma, ready for mapping |
| State coverage | Ready | All 4 states defined |
| Icon slots | Partial | leading-icon uses Placeholder instance (OK). trailing-icon uses RECTANGLE (blocked). |
| Action slot | Ready | Renamed to action-button — semantic slot name, ready for Code Connect mapping |
| Native component file | Pending | EBLabeledField.swift / EBLabeledField.kt not yet created |
4 State values × 2 isFilled values.
| State | isFilled | Node ID |
|---|---|---|
| Default | true | 17758:3714 |
| Default | false | 17758:3723 |
| Active | true | 17758:3732 |
| Active | false | 17758:3741 |
| Error | true | 17758:3750 |
| Error | false | 17758:3759 |
| Disabled | true | 17758:3768 |
| Disabled | false | 17758:3777 |
Button - XSmall renamed to action-button. Now uses a semantic slot name, enabling flexible consumer customization and clean Code Connect mapping. FixedisFilled values changed from Yes/No to true/false. Now maps directly to Swift Bool and Kotlin Boolean for Code Connect. Fixedstate renamed to State (capitalized) to align with sibling Form Elements fields (Input Field, etc.). FixedisFilled=Yes/No instead of true/false. Incompatible with Swift Bool and Kotlin Boolean for Code Connect mapping. Openstate uses lowercase, inconsistent with other Form Elements using State (capitalized). OpenButton - XSmall is not a named action slot, limiting consumer customization. Openicon-placeholder in trailing-icon is a RECTANGLE, not a swappable icon instance. OpenThe 16 × 16 leading marker atom for a List Item — checks, pending dots, bullets, hollow dots, squares, numbered indicators, and a custom asset slot. Currently exposes three entangled properties (type, indicator, state) that only produce ~10 valid combinations out of 72 theoretical.
type × indicator × state axes with a single semantic variant enum (check, check-positive, pending, pending-notice, bullet, hollow, square, numbered, custom). Replace the Custom placeholder circle with a Figma Slot so product teams can drop in any 16×16 asset without instance-swap.List Item Asset appears inside List Items — see the List Item preview for the composed layout.
Pick a variant to see the 16 × 16 marker render.
type, indicator, state) that shouldn't multiply freely — most combinations are invalid. Creates phantom variants in Figma's variant picker. C2indicator=Custom exposes a gray circle icon-placeholder instead of a Figma Slot. Blocks product teams from dropping in custom 16 × 16 assets without local overrides. C6- Variant matrix is entangled.
type×indicator×state=72 theoretical combinations but only ~10 are valid. Flatten into one semanticvariantenum to eliminate invalid combos. C2 · Variant & Property Naming - Numbered indicator hardcodes "1." No way to pass the ordinal — numbered lists of 5 items all show "1." in Figma. Expose a
numbertext property. C5 · Interaction State Coverage indicator=Customis a hardcoded gray circle. Should be a Figma Slot that accepts any 16×16 asset via instance swap. C6 · Asset & Icon Quality- Code Connect mappings not registered. Blocked until variant flatten and slot adoption land. C7 · Code Connect Linkability
- Flatten the variant matrix — replace
type+indicator+statewith a single semanticvariantenum:check/check-positive/pending/pending-notice/bullet/hollow/square/numbered/custom. ~9 real variants, no invalid combinations possible. Property - Adopt a Figma Slot for
variant=custom— declare a namedassetslot so product teams can drop in any 16 × 16 component instance without editing the master. Maps cleanly to a@ViewBuilderslot (SwiftUI) or@Composableslot (Compose) via Code Connect. Slot - Parameterize the numbered indicator — expose a
numbertext property so ordered lists can show the actual ordinal. In Figma this is a text override; in native it's anumber: Intparam. Property
9 real variants under the proposed flattened enum. All render inside a 16 × 16 bounding box.
Row of all 9 markers at actual size. Left to right: check, check-positive, pending, pending-notice, bullet, hollow, square, numbered, custom (slot).
| Variant | Role | Token | Value |
|---|---|---|---|
| check | icon | main/list-item/color/default/icon-item | #90A8D0 |
| check-positive | icon | main/list-item/color/positive/icon-item | #27C990 |
| pending | icon | main/list-item/color/default/icon-item | #90A8D0 |
| pending-notice | icon | main/list-item/color/notice/icon-item | #CA970C |
| bullet / hollow / square | icon | main/list-item/color/default/icon-item | #90A8D0 |
| numbered (bg) | container bg | main/list-item/color/default/icon-bg | #EEF2F9 |
| numbered (label) | number text | main/list-item/color/default/icon-item | #90A8D0 |
| custom | — | (slot — inherits from provided asset) | — |
| Property | Token | Value |
|---|---|---|
| Bounding box | — | 16 × 16 |
| Check / Pending icon | — | 16 × 16 vector |
| Bullet / Hollow / Square size | — | 5 × 5 |
| Numbered container radius | — | 16px (pill) |
| Numbered padding | — | 4L / 2R / 2v |
| Vertical padding (most variants) | space/space-2 | 2px |
| Variant | DS text style | Spec |
|---|---|---|
| numbered (pill) | Primary/Label/Fine | HeyMeow Rnd Bold · 12 / 12 · +0.5 |
| Ordered / Normal (legacy) | Primary/Label/Light/Small | HeyMeow Rnd Semibold · 14 / 14 · +0.25 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:list:1.0.0") }
| Current Figma | Proposed | SwiftUI | Compose |
|---|---|---|---|
| type × indicator × state | variant (enum) | .ebVariant(.check) | variant=EBListMarker.Check |
| — (hardcoded "1.") | number: Int? | number: Int? | number: Int? |
| indicator=Custom placeholder | Figma Slot → ViewBuilder / @Composable | @ViewBuilder asset | asset: @Composable () -> Unit |
// Standard variants EBListMarker(variant: .check) EBListMarker(variant: .checkPositive) EBListMarker(variant: .pendingNotice) // Numbered EBListMarker(variant: .numbered, number: 1) // Custom — Figma Slot maps to @ViewBuilder EBListMarker { Image(systemName: "star.fill") .foregroundStyle(.yellow) }
// Standard variants EBListMarker(variant = EBListMarker.Check) EBListMarker(variant = EBListMarker.CheckPositive) EBListMarker(variant = EBListMarker.PendingNotice) // Numbered EBListMarker(variant = EBListMarker.Numbered, number = 1) // Custom — Figma Slot maps to @Composable slot EBListMarker { Icon(painterResource(R.drawable.star), contentDescription = null) }
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic: container, Pending, Checkmark, shape_full. |
| C2 | Variant & Property Naming | Needs Fix | Three entangled axes; most combinations invalid. Flatten to single variant enum. |
| C3 | Token Coverage | Ready | All colors, typography, spacing bound to tokens. |
| C4 | Native Mappability | Ready | Custom atom on both platforms — straightforward render switch. |
| C5 | Interaction State Coverage | Partial | Numbered hardcodes "1."; no per-item ordinal. Otherwise display-only. |
| C6 | Asset & Icon Quality | Needs Fix | Custom indicator is a gray circle; should be a Figma Slot. |
| C7 | Code Connect Linkability | Pending | Not mapped. Clean mapping lands after flatten + Slot adoption. |
| type | indicator | state | Node ID |
|---|---|---|---|
| Placeholder | Placeholder | Default | 18482:34426 |
| Unordered | Pending | Default | 18482:34407 |
| Unordered | Pending | Notice | 18482:34409 |
| Unordered | Check | Default | 18482:34411 |
| Unordered | Check | Positive | 18482:34413 |
| Unordered | Bullet | Default | 18482:34415 |
| Unordered | Hollow | Default | 18482:34417 |
| Unordered | Square | Default | 18482:34419 |
| Ordered | Custom | Default | 18482:34423 |
| Ordered | Normal | Default | 18482:34421 |
After flatten + Slot adoption, these 10 collapse to 9 semantic variants + 1 custom slot — 0 invalid combinations possible.
variant enum. Opennumber parameter. OpenA single row in a list: leading asset + body text. 3 variants by level (1, 2, 3) that control indent (0 / 16 / 32 px). Composes a List Item Asset instance for the leading marker.
asset Slot so consumers can drop in any List Item Asset variant (or a custom 16 × 16 component) directly. Maps 1 : 1 to @ViewBuilder / @Composable slots for Code Connect. Also rename level to an integer or drop it in favor of nesting-based indent.Contexts are illustrative. Final screens will reference actual GCash patterns. List Items compose into multi-line lists on terms pages, onboarding steps, and task checklists.
Toggle level + asset variant. The asset slot accepts any List Item Asset.
Secondary/Bold/Base (BarkAda Semibold 14/20).level uses string values ("1"/"2"/"3") instead of integers. Figma won't let you parameterize it either — each level is a separate variant. C2levelproperty uses string values. Should be an integer, or — ideally — dropped in favor of indent inferred from nesting depth inside the List container. C2 · Variant & Property Naming- Leading asset is an instance-swap placeholder. Adopt Figma Slots so the asset becomes a first-class slot mapping to
@ViewBuilder(SwiftUI) /@Composable(Compose) via Code Connect. C6 · Asset & Icon Quality - Code Connect mappings not registered. Blocked until
levelrename and the asset-slot adoption land. C7 · Code Connect Linkability
- Adopt a Figma Slot for the asset — declare a named
assetslot that accepts any List Item Asset instance (or a bare 16 × 16 component). Native maps to@ViewBuilder(SwiftUI) /@Composableslot (Compose) — Code Connect reads it as a real slot parameter. Slot - Rename or drop
level— if keeping the prop, change string values to integers. Better: drop the variant entirely and let indent come from nesting depth inside the List container. Rename - Consider a Trailing slot — product patterns often pair list items with trailing counters, badges, or chevrons. Adding a
trailingslot future-proofs the component without creating variant explosion. Slot
3 variants split by indent level. Width ranges from hug (level 1) to 294px (levels 2 + 3).
Base row. Asset + 270px body. No indent.
Indented 16px. Body fills remaining width of the 294px container.
Indented 32px. Deepest supported level.
| Role | Token | Value |
|---|---|---|
| Body text | main/list-item/color/default/description | #445C85 |
Asset colors are documented on List Item Asset.
| Property | Token | Value |
|---|---|---|
| Asset → body gap | space/space-8 | 8px |
| Level 2 indent | space/space-16 | 16px |
| Level 3 indent | space/space-32 | 32px |
| Level 1 body width | — | 270px |
| Levels 2 + 3 container width | — | 294px |
| Body alignment | — | items-start (asset top-aligned) |
| Element | DS text style | Spec |
|---|---|---|
| Body | Secondary/Bold/Base | BarkAda Semibold · 14 / 20 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:list:1.0.0") }
| Figma | SwiftUI | Compose | Notes |
|---|---|---|---|
| asset (Slot) | @ViewBuilder leading | leading: @Composable () -> Unit | First-class slot — accepts any List Item Asset or custom 16 × 16 component |
| level=1/2/3 | level: Int | level: Int | Controls indent. Better dropped entirely with nesting-based indent. |
| text content | content: String | content: String | Body text |
// Default bullet EBListItem("Transactions are reviewed within 24 hours.") { EBListMarker(variant: .bullet) } // Ordered with number EBListItem("Enter your GCash PIN to continue.") { EBListMarker(variant: .numbered, number: 1) } .level(2) // Custom asset via slot EBListItem("Saved to Favorites") { Image(systemName: "heart.fill") .foregroundStyle(.red) }
// Default bullet EBListItem(content = "Transactions are reviewed within 24 hours.") { EBListMarker(variant = EBListMarker.Bullet) } // Ordered with number, level 2 EBListItem( content = "Enter your GCash PIN to continue.", level = 2 ) { EBListMarker(variant = EBListMarker.Numbered, number = 1) } // Custom asset via slot EBListItem(content = "Saved to Favorites") { Icon(painterResource(R.drawable.heart), contentDescription = null, tint = Color.Red) }
| Requirement | iOS | Android |
|---|---|---|
| List container role | Wrap List Items in a List for VoiceOver row semantics | Use Modifier.semantics { collectionInfo=... } on parent |
| Marker semantics | Decorative markers: .accessibilityHidden(true) on the asset | Same — contentDescription=null |
| Numbered lists | Prepend number to the announced label, or use .accessibilityValue | Include number in contentDescription |
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Simple row: asset + body text. |
| C2 | Variant & Property Naming | Partial | level uses string values; consider dropping the prop entirely. |
| C3 | Token Coverage | Ready | Body color, indent, gap tokens all bound. |
| C4 | Native Mappability | Ready | HStack / Row with slot. |
| C5 | Interaction State Coverage | Ready | Display-only; interaction lives on the consumer. |
| C6 | Asset & Icon Quality | Partial | Instance-swap works; Figma Slot would be cleaner and map to native slot params. |
| C7 | Code Connect Linkability | Pending | Not mapped. Slot adoption improves mapping quality. |
| level | Indent | Width | Node ID |
|---|---|---|---|
| 1 | 0 | hug (body 270px) | 18482:34430 |
| 2 | 16px | 294px | 18482:34433 |
| 3 | 32px | 294px | 18482:34436 |
level uses string values — Should be integer or dropped in favor of nesting-based indent. OpenA frame containing 8 hardcoded List Item instances stacked with an 8px gap. No component properties, no variants. Functions as a layout example on the sticker sheet rather than a reusable component.
The List frame as-is: 8 List Item instances with an 8px gap. Three of them use indent levels to demonstrate nesting.
- Not a real component. List today is a frame with 8 hardcoded List Item instances — no property set, no variants, no slots. Consumers can't populate it with their own items without detaching and rebuilding from the List Item atom. C2 · Variant & Property Naming
- Remove List from the sticker sheet. Publish List Item (and List Item Asset) as the shipped components. Consumers stack List Items themselves using auto-layout — same pattern as plain text lists. Keeps the sticker sheet focused on reusable atoms. Family
- Or restructure into a real container. Expose a single flexible List component with auto-layout that accepts a collection of List Items (same approach proposed for Tabs dropping
tabsCount). Native maps toForEach/LazyColumn. Provides a documented home for list-level concerns like spacing, dividers, or separators. Property - If restructuring, consider adding list-level props for spacing (
gap=8/12/16) and an optional divider between items. These are the decisions that logically belong to a container, not an item. Property
Since List isn't a real component today, native consumers simply stack List Items. If the restructure option is pursued, a real EBList composable would wrap the iteration.
// Option A — just stack List Items VStack(alignment: .leading, spacing: 8) { ForEach(items) { item in EBListItem(item.text) { EBListMarker(variant: item.marker) } } } // Option B — EBList container (if restructure) EBList(items: items, gap: 8) { item in EBListItem(item.text) { EBListMarker(variant: item.marker) } }
// Option A — just stack List Items Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { items.forEach { item -> EBListItem(content = item.text) { EBListMarker(variant = item.marker) } } } // Option B — EBList container (if restructure) EBList(items = items, gap = 8.dp) { item -> EBListItem(content = item.text) { EBListMarker(variant = item.marker) } }
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Trivial wrapper — List Item children named correctly. |
| C2 | Variant & Property Naming | N/A | No properties. |
| C3 | Token Coverage | Ready | Gap bound to space/space-8. |
| C4 | Native Mappability | N/A | Maps to plain VStack / Column. No DS-specific component needed unless restructured. |
| C5 | Interaction State Coverage | N/A | Layout only. |
| C6 | Asset & Icon Quality | N/A | No assets. |
| C7 | Code Connect Linkability | N/A | Not a linkable DS component in current form. |
A centered dialog layer that surfaces content requiring user interaction on top of a dimmed page. Ships as 7 variants crossing type (default / with icon / transaction_v1 / transaction_v2) and cta (1 / 1-vertical / 2-horizontal / 2-vertical). Uses main/modal-popup/color/* tokens for bg, label, border, and accent. Description copy is typeset in the secondary font BarkAda.
Modal component is trying to serve both a general-purpose dialog (default / with icon) and a specialised transaction-receipt layout (transaction_v1 / v2). These are not "variants" of the same thing — they have different information architectures, different tokens, and different native mappings. On top of that, Modal overlaps in name and scope with the existing Overlay component (47:329691), which currently ships the scrim only. Consolidate the family: one canonical Modal that owns the surface + scrim, and a separate Transaction Receipt Card for the receipt layout.Modal sits centered over a dimmed page with the Overlay scrim behind it. It blocks interaction with background content until the user resolves the dialog.
Toggle between the four type values and the four cta arrangements. Not every combination exists in Figma — the picker falls back to the closest shipped variant.
transaction_v1 / transaction_v2 use snake_case, 1 - vertical / 2 - horizontal use space-dashed-space. Also duplicates scope with the Overlay component.| Behavior | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Present / dismiss | Yes | Yes | Not annotated | Scale-in + fade animation is implied by pattern but not documented on the component. |
| Tap-outside dismiss | Yes | Yes | Not annotated | Overlay owns the tap-region. Contract should be documented (dismissible vs. modal). |
| CTA resolution | Yes | Yes | Via Button child | 1 / 2-horizontal / 2-vertical layouts maped by cta property. |
| Copy to clipboard (transaction) | Yes | Yes | Icon only, no state | Copy icon is raster, with no pressed / success feedback state defined. |
| Focus trap / a11y | Yes | Yes | Implicit | Focus should be trapped inside the modal while open; restore to trigger on close. |
- Duplicate scope with Overlay component. A separate
Overlayrecord (node47:329691) already owns the scrim primitive; this Modal should compose it, not re-declare the modal surface. Today the two are maintained independently and there's no annotation describing which is canonical. C7 · Code Connect Linkability - Two unrelated layouts compressed into one component.
type=defaultandtype=with iconare general-purpose dialog shapes, whiletype=transaction_v1andtype=transaction_v2are transaction-receipt layouts with their own inner rows and reference-number slot. These are different components masquerading as variants. C4 · Native Mappability - Mixed enum casing within a single property.
typeusesdefault,with icon(space),transaction_v1,transaction_v2(snake_case).ctauses1,1 - vertical,2 - horizontal,2 - vertical(spaces around dashes). Neither is consistent with the rest of the DS. C2 · Variant & Property Naming - Opacity-0 spacer frames instead of gap. Inner layers named
_space_16,_space_12withopacity:0are used to create vertical rhythm. These are non-semantic and don't translate to native auto-layout. Usegapon the parent instead. C1 · Layer Structure & Naming - Raster copy-to-clipboard icon. The transaction variants use PNG assets
shape_half/shape_fullfor the copy icon. Should be a vector icon instance bound tomain/modal-popup/color/icon-copy. C6 · Asset & Icon Quality - Icon-placeholder slot is a grey circle, not an instance swap. The
with iconvariants render a raw#C2C6CFcircle (icon-placeholder). Consumers can't swap in a real icon without detaching. Should be a Slot backed by theIconcomponent. C1 · Layer Structure & Naming - No interaction states on the modal surface. The component ships only a default state — no pressed / dragging state for the CTA group, no loading state for async actions, and no entrance/exit transition annotation. C5 · Interaction State Coverage
- Missing CTA combos. Transaction variants only ship with
cta=1; "with icon" only ships with vertical CTAs. Thectaaxis is not complete across alltypevalues, so designers resort to detaching when they need another arrangement. C2 · Variant & Property Naming - No Code Connect mapping. Blocked on restructure — once the transaction layout is extracted and the enum values are cleaned up, mapping is trivial. C7 · Code Connect Linkability
- Consolidate with Overlay — one canonical Modal family. The current split (Overlay=scrim only, Modal=surface + scrim baked in) duplicates intent. Rename and re-partition into:
Overlay(scrim primitive, already assessed) +Modal(surface composition that references Overlay) +TransactionReceipt(the transaction_v1/v2 layout pulled out as its own card). Document which component owns which concern. Family - Extract transaction_v1 / transaction_v2 as a separate component. Move the receipt layout into a new
Transaction Receiptcomponent (likely a composition of List + Reference Number + Copy action), and drop thetransaction_*values from Modal'stypeenum. Modal keeps only general-dialog variants. Composition - Normalise enum casing. Pick one convention for all multi-word values. Recommendation: single lowercase words separated by dashes —
default,with-icon,cta-single,cta-single-vertical,cta-double-horizontal,cta-double-vertical. Align with Button and other DS components. Rename - Replace opacity-0 spacer frames with auto-layout gap. Remove the
_space_16/_space_12invisible rectangles and setgapon the parent auto-layout frames (usingspace/space-16andspace/space-12tokens). Native translators can then emit properspacingparameters. Property - Convert icon-placeholder into a Figma Slot. Add a named
iconslot to thewith iconvariants backed by the DS Icon component, so designers can instance-swap without detaching. Default to a neutral status icon. Slot - Replace raster copy icon with a vector instance. Swap
shape_half/shape_fullPNGs for the DS vector Copy icon and bind colour tomain/modal-popup/color/icon-copy. While there, add a pressed / copied success state. Asset - Complete the CTA matrix. Ship every
type × ctacombination (or constrain the schema so unsupported combos aren't implied). Currentlydefaultis missing1-vertical,with iconis missing horizontal pairs, and transactions only ship withcta=1. Either fill the gaps or reshape the enum. Property - Add loading and destructive states. Modals routinely host async confirmations — add a
state=loadingvariant (CTA replaced with spinner) and surface destructive-action styling via a boolean or via the child Button's existingisErrorprop. State - Annotate the dismiss contract. Document on the component: entrance / exit animation, focus trap, restore-focus-on-close, ESC-to-dismiss, tap-outside-dismiss. Developers currently have to infer these from adjacent patterns. Docs
- Title + description copy should come from the DS text styles. Title is bound to
Primary/Headlines/Sectionand description toSecondary/Default/Base(BarkAda). Confirm the secondary-font description is intentional — flag as the standing custom-font action item if not. Docs
18507:71792The general-purpose dialog. Title + description + single CTA on a white card. Use for confirmations, errors, and neutral informational prompts.
Modaldefault1 / 2 - horizontal / 2 - vertical| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface bg | main/modal-popup/color/bg | #FFFFFF |
| Title label | main/modal-popup/color/label | #0A2757 |
| Description | main/modal-popup/color/label-primary | #6780A9 |
| Border | main/modal-popup/color/border | #E5EBF4 |
| Shadow | Shadow/Depth 0 | #E8EEF2 · 79% |
main/modal-popup/color/* collection. No hardcoded values in the default variant.32021221227024 / 32 top · 24 sides6none (shadow only)py 24Primary/Headlines/SectionProxima Soft · Bold22 / 26Secondary/Default/BaseBarkAda · Medium14 / 20center// Default modal · single CTA .sheet(isPresented: $showModal) { EBModal( title: "Put the title here", description: "Add description here." ) { EBButton("Label") { showModal = false } } .presentationDetents([.medium]) }
// Default modal · single CTA if (showModal) { EBModal( title = "Put the title here", description = "Add description here.", onDismissRequest = { showModal = false } ) { EBButton(onClick = { showModal = false }) { Text("Label") } } }
18507:71773 / 18507:71783Dialog that leads with a 92×92 icon to set tone — success, warning, or info. CTAs stack vertically (1 or 2).
with icon1 - vertical / 2 - vertical| ROLE | TOKEN | VALUE |
|---|---|---|
| Surface bg | main/modal-popup/color/bg | #FFFFFF |
| Title label | main/modal-popup/color/label | #0A2757 |
| Description | main/modal-popup/color/label-primary | #6780A9 |
| Icon placeholder | (hardcoded) | #C2C6CF |
#C2C6CF is hardcoded — it should be replaced with a proper Icon slot that binds its colour to a semantic token.32031237092 × 92~72.5 (circle)16168 (vertical)Primary/Headlines/SectionProxima Soft · Bold · 22 / 26Secondary/Default/BaseBarkAda · Medium · 14 / 20center// With icon · two vertical CTAs .sheet(isPresented: $showModal) { EBModal( icon: Image("success-badge"), title: "Transfer successful", description: "Funds sent to Juan Dela Cruz." ) { EBButton("Done") { showModal = false } EBOutlinedButton("Share receipt") { … } } }
// With icon · two vertical CTAs EBModal( icon = { Icon(painterResource(R.drawable.success_badge), null) }, title = "Transfer successful", description = "Funds sent to Juan Dela Cruz.", onDismissRequest = { showModal = false } ) { EBButton(onClick = { showModal = false }) { Text("Done") } EBOutlinedButton(onClick = {}) { Text("Share receipt") } }
18507:71706 / 18507:71732Receipt-style dialog used for order, transfer, and subscription summaries. v1 stacks label + value per row; v2 is horizontal. Both include a reference-number row with copy-to-clipboard. Recommended for extraction into its own TransactionReceipt component.
transaction_v1 / transaction_v21 (only)shape_half, shape_full)| ROLE | TOKEN | VALUE |
|---|---|---|
| Content bg | main/modal-popup/color/bg | #FFFFFF |
| Footer bg | main/modal-popup/color/bg-subtle | #F6F9FD |
| Title / value | main/modal-popup/color/label | #0A2757 |
| Row label | main/modal-popup/color/label-primary | #6780A9 |
| Divider | main/modal-popup/color/border | #E5EBF4 |
| Copy icon | main/modal-popup/color/icon-copy | #005CE5 |
32039840424 all sides16 top · 8 bottom · 24 sides8 top · 24 bottom · 24 sides121224 × 24Primary/Headlines/SectionProxima Soft · Bold · 22 / 26Primary/Multi-line Label/Light/Base · 16 / 20Primary/Label/Light/Small · 14 / 14Primary/Multi-line Label/Light/Small · 14 / 16left (v1/v2 body), center (title)// Recommended — composed from extracted receipt component .sheet(isPresented: $showReceipt) { EBModal(title: "Transaction details") { EBTransactionReceipt( rows: rows, referenceNumber: "165A25912345", layout: .inline // or .stacked ) EBButton("Done") { showReceipt = false } } }
// Recommended — composed from extracted receipt component EBModal( title = "Transaction details", onDismissRequest = { showReceipt = false } ) { EBTransactionReceipt( rows = rows, referenceNumber = "165A25912345", layout = ReceiptLayout.Inline ) EBButton(onClick = { showReceipt = false }) { Text("Done") } }
// Swift Package Manager .package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
// build.gradle.kts implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBModal
| Figma property | SwiftUI | Compose |
|---|---|---|
type=default | EBModal(title:, description:) | EBModal(title, description) |
type=with icon | EBModal(icon:, title:, description:) | EBModal(icon={ … }, title, description) |
type=transaction_v1 / v2 | Extract → EBTransactionReceipt(layout: .stacked / .inline) | Extract → EBTransactionReceipt(layout=Stacked / Inline) |
cta=1 | { EBButton(…) } (single trailing closure) | content: { EBButton(…) } |
cta=1 - vertical | Implicit — single button is always full-width | Implicit — single button is always full-width |
cta=2 - horizontal | { EBButton(…); EBOutlinedButton(…) } + .ctaLayout(.horizontal) | ctaLayout=CtaLayout.Horizontal |
cta=2 - vertical | .ctaLayout(.vertical) | ctaLayout=CtaLayout.Vertical |
| (proposed) Dismissible | .interactiveDismissDisabled(!dismissible) | DialogProperties(dismissOnClickOutside=dismissible) |
| (proposed) Loading state | .ebLoading(isLoading) | isLoading=true |
ios/Components/Modal/EBModal.swiftios/Components/Modal/EBTransactionReceipt.swift(extracted)android/components/modal/EBModal.ktandroid/components/modal/EBTransactionReceipt.kt(extracted)
// 1 · Default · single CTA .sheet(isPresented: $showConfirm) { EBModal(title: "Delete account?", description: "This cannot be undone.") { EBButton("Delete", role: .destructive) { delete() } } } // 2 · With icon · two vertical CTAs .sheet(isPresented: $showSuccess) { EBModal( icon: Image("check-badge"), title: "Transfer successful", description: "₱1,500 sent to Juan Dela Cruz." ) { EBButton("Done") { showSuccess = false } EBOutlinedButton("Share receipt") { share() } } .ctaLayout(.vertical) } // 3 · Two horizontal CTAs (cancel/confirm) .sheet(isPresented: $showConfirm) { EBModal(title: "Send money?", description: "You're about to send ₱1,500.") { EBOutlinedButton("Cancel") { showConfirm = false } EBButton("Confirm") { confirm() } } .ctaLayout(.horizontal) } // 4 · Transaction receipt (extracted — recommended) .sheet(isPresented: $showReceipt) { EBModal(title: "Order details") { EBTransactionReceipt(rows: rows, referenceNumber: ref) EBButton("Done") { showReceipt = false } } }
// 1 · Default · single CTA if (showConfirm) { EBModal( title = "Delete account?", description = "This cannot be undone.", onDismissRequest = { showConfirm = false } ) { EBButton(onClick = ::delete, colors = EBButtonDefaults.destructiveColors()) { Text("Delete") } } } // 2 · With icon · two vertical CTAs EBModal( icon = { Icon(painterResource(R.drawable.check_badge), null) }, title = "Transfer successful", description = "₱1,500 sent to Juan Dela Cruz.", ctaLayout = CtaLayout.Vertical, onDismissRequest = { } ) { EBButton(onClick = {}) { Text("Done") } EBOutlinedButton(onClick = ::share) { Text("Share receipt") } } // 3 · Transaction receipt (extracted — recommended) EBModal(title = "Order details", onDismissRequest = {}) { EBTransactionReceipt(rows = rows, referenceNumber = ref) EBButton(onClick = {}) { Text("Done") } }
| Requirement | iOS | Android |
|---|---|---|
| Modal trait | Apply .accessibilityAddTraits(.isModal) — VoiceOver trap focus inside. | Use Dialog / AlertDialog — TalkBack treats content as modal by default. |
| Focus management | Focus moves to modal on present; restores to trigger on dismiss. | Focus enters dialog content on show; restored to trigger element on dismiss. |
| Title announcement | Bind the title Text as the accessibilityHeading so it's read first. | Use Modifier.semantics { heading() } on the title; set paneTitle on the surface. |
| Dismiss gesture | ESC / tap-outside / swipe-down should all route through one dismiss handler. | Back gesture + tap-outside configured via DialogProperties(dismissOnBackPress, dismissOnClickOutside). |
| Destructive action | Use role: .destructive on the CTA so VoiceOver announces destructive intent. | Use destructive colour palette; set contentDescription on CTA explicitly. |
| Copy to clipboard | Announce "Copied" via UIAccessibility.post(.announcement, …). | Announce via view.announceForAccessibility("Copied"). |
| Tap target (copy icon) | Wrap 24×24 icon in a ≥44×44 tappable area. | Wrap 24×24 icon in a ≥48×48 dp tappable area. |
- Use Modal for high-friction confirmations, destructive actions, and success / error acknowledgements.
- Always pair the modal surface with the Overlay scrim — never float the card on bare background.
- Lead with a clear title; keep description to two lines where possible.
- Reserve the
with iconvariant for tone-setting moments (success, warning, error). - Extract Transaction variants into a dedicated Receipt component and compose it inside Modal.
- Don't use Modal as a full-screen navigation surface — use a route / screen instead.
- Don't stack modals on top of each other; resolve the current one first.
- Don't hardcode the icon-placeholder grey (
#C2C6CF) — swap in a real DS Icon instance. - Don't rely on the opacity-0 spacer frames for rhythm — they don't translate to native.
- Don't fork Modal or Overlay into a third "Modal" variant somewhere else in the file.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Needs Refinement | Opacity-0 _space_* spacer rectangles used instead of gap. Icon-placeholder is a raw circle node, not a named icon slot. |
| C2 | Variant & Property Naming | Needs Refinement | Mixed casing: transaction_v1 (snake) vs 1 - vertical (space-dash-space). CTA matrix is sparse across type values. |
| C3 | Token Coverage | Ready | bg, label, label-primary, border, bg-subtle, icon-copy all bound to main/modal-popup/color/*. Shadow uses Shadow/Depth 0. |
| C4 | Native Mappability | Requires Rework | General-dialog variants map to .sheet / Dialog; transaction variants do not — they need a dedicated Receipt component. Not a single native primitive. |
| C5 | Interaction State Coverage | Needs Refinement | Only default state shipped. No loading, no destructive variant, no copy-success feedback, no present/dismiss transition annotation. |
| C6 | Asset & Icon Quality | Needs Refinement | Copy icon is raster PNG (shape_half, shape_full). Icon-placeholder slot is a hardcoded grey circle instead of a vector icon instance. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on restructure — duplicate scope with Overlay + embedded transaction layout must be resolved before mapping. |
| Aspect | Status | Notes |
|---|---|---|
| Component boundary | Requires Rework | Needs to be split into Modal + TransactionReceipt before mapping. |
| Property names | Needs Refinement | Normalise casing first; otherwise mapping will be 1:n (one Figma value → multiple code enums). |
| Token bindings | Ready | All colour tokens present in the main/modal-popup collection. |
| Slot architecture | Needs Refinement | Promote icon and CTA to Figma Slots so native can map to trailing closures / composable content slots. |
| State coverage | Needs Refinement | Add loading and destructive states to cover the common async-confirmation flows. |
A type × cta matrix would yield 4 × 4=16, but only 7 combinations are shipped. The rest are gaps.
| Type | CTA | Node | Dimensions | Notes |
|---|---|---|---|---|
| default | 1 | 18507:71792 | 320 × 212 | Title + description + single CTA. |
| default | 2 - horizontal | 18507:71799 | 320 × 212 | Outlined secondary + filled primary, side-by-side. |
| default | 2 - vertical | 18507:71807 | 320 × 270 | Stacked CTAs, both full-width. |
| with icon | 1 - vertical | 18507:71783 | 320 × 312 | 92×92 icon placeholder + title + desc + single CTA. |
| with icon | 2 - vertical | 18507:71773 | 320 × 370 | 92×92 icon + title + desc + two stacked CTAs. |
| transaction_v1 | 1 | 18507:71706 | 320 × 398 | Receipt layout — rows stacked (label above value). Reference row with copy icon. |
| transaction_v2 | 1 | 18507:71732 | 320 × 404 | Receipt layout — rows inline (label left, value right). Reference row with copy icon. Outer surface uses bg-subtle. |
Gaps:default × 1 - vertical, with icon × 1 / 2 - horizontal, and every transaction × (non-1) combination are not shipped. Either fill the matrix or constrain the enum to only advertise supported pairs.
47:329691) are maintained independently but overlap in intent. Family consolidation required. Open_space_* spacer rectangles + hardcoded icon-placeholder circle. Opentransaction_v1 vs 1 - vertical). Sparse CTA matrix. OpenEBTransactionReceipt. Openshape_half / shape_full PNGs should be a vector icon instance. OpenBarkAda (secondary font). Confirm with design — otherwise covered by the standing custom-font action item. InfoThe 100×32 selectable cell rendered inside the Date Picker - Group Type=Month and Type=Year views. 3 variants on a single axis: Type=Default | Today | Selected. No Disabled, no Pressed, no Focused — a narrower state axis than the sibling day cell. The same Figma component is instance-swapped as both a month cell (Jan, Feb, Mar…) and a year cell (2024, 2025…); the inner frame is named Month regardless. Sibling to Date Picker - Item (32×32 day cell) — both are the same selectable-cell primitive at different pixel sizes.
Picker Cell with kind: day | month | year + state: default | today | selected | disabled. This cell currently lacks Disabled, Pressed, and Focused entirely — the unification should rectangularize the state axis across all kinds.The cell is instance-swapped inside Date Picker - Group when the user switches to Month or Year view. 12 cells are rendered as a 4 × 3 month grid, or 20 cells as a 4 × 5 year grid. Same Figma component for both — only the text label changes.
Switch Type to compare the 3 published variants. There is only one axis — the cell does not publish Disabled, Pressed, or Focused states.
Picker Cell with a kind axis would be truly reusable.DatePicker(.graphical) and Material 3 DatePicker render their own month/year views. And as a DS primitive it duplicates Date Picker - Item at a different size instead of being one Picker Cell with a kind axis.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | Type=Default | Plain label on white. #0A2757 primary label. No ring, no fill. |
| Today | Yes | Yes | Type=Today | 1px blue border, blue label. Marks the current month or year. Border thickness differs from the sibling day cell's 1.5px ring. |
| Selected | Yes | Yes | Type=Selected | Solid blue fill (#005CE5), white bold label. Uses the shared main/date-picker/day/color/selected/* token scope. |
| Disabled | No | No | — | Not defined on any Type. Sibling day cell exposes State=Disabled for Default and Today — this cell has no equivalent, so a disabled month or year has no token-bound appearance. |
| Pressed / Focused / Hover | No | No | — | Not defined. Native pickers supply these automatically, but a custom wrapper has no tokens to apply. |
| Today + Selected | No | No | — | Not defined. Unclear which presentation wins when the current month/year is also the selected one. |
| Touch target | 100×32 | 100×32 | — | Height is 32px — below WCAG minimum 44×44 on the vertical axis. Native pickers enforce their own hit areas; any custom wrapper would need to pad the tap zone. |
- Sibling duplication with Date Picker - Item. The two components are the same selectable-cell primitive at different pixel sizes (100×32 vs 32×32) with overlapping state semantics (Default, Today, Selected). Maintaining them as siblings doubles the variant surface and forces the Group panel to instance-swap based on view. C1 · Layer Structure & Naming
- Inner frame is always named
Month— even when it's the year cell. The same Figma component is instance-swapped as both a month cell (Jan, Feb, Mar…) and a year cell (2024, 2025…), but the inner auto-layout frame is namedMonthin all three variants. The layer name lies for half the instances. C1 · Layer Structure & Naming - State axis is narrower than the sibling day cell. The day cell has
Type×State=Enabled|Disabled. This cell has a singleTypeaxis with no State at all. Cells in the same family should share the same state shape — even if specific values differ bykind. C2 · Variant & Property Naming - Tokens named
main/date-picker/day/*are used for non-day cells. The cell's bg and label resolve tomain/date-picker/day/color/selected/*andmain/date-picker/day/color/unselected/*— but it's rendering a month or year, not a day. The token scope is misleadingly named. Either rename tomain/date-picker/cell/*or split intomain/picker-cell/{day|month|year}/*. C3 · Token Coverage - Cell has no 1:1 native primitive. Both SwiftUI
DatePicker(.graphical)and Material 3DatePickerrender their own month/year views and don't accept a custom cell view. This Figma component is therefore a reference spec, not a mappable component — should be marked as such or merged into the Picker Cell family. C4 · Native Mappability - No Disabled variant. When a parent limits the pickable date range, disabled months or years have no token-bound appearance on this cell. The sibling day cell at least ships Disabled on Default and Today — this cell has no Disabled form for any Type. C5 · Interaction State Coverage
- No Pressed, Hover, or Focused variants. Tap feedback (iOS ripple / Android state layer) and keyboard focus ring are not defined. Native pickers supply these automatically, but any custom wrapper has no tokens to apply. C5 · Interaction State Coverage
- Today + Selected collision is unresolved. There is no variant for the common case of the current month/year being the selected one. The design team should decide which presentation wins (ring + fill, fill-only, or a hybrid) and publish a variant. C5 · Interaction State Coverage
- Today ring thickness drifts from the sibling day cell. This cell uses a 1px border; the sibling day cell uses a 1.5px ring. Same visual pattern, different stroke weight — selection/today emphasis should be one token shared across Picker Cell kinds, not per-component values. C6 · Asset & Icon Quality
- Code Connect mappings not registered. Blocked by the native-pickers-own-it direction (C4) and by the pending Picker Cell family unification (C1). Map only once the unified component exists and the wrapper surface is confirmed. C7 · Code Connect Linkability
- Family — Consolidate Date Picker - Item + Month and Year Picker - Item into ONE
Picker Cell. Both are selectable cells with identical state semantics (Default / Today / Selected); only pixel size (32×32 vs 100×32) and corner radius (30px pill vs 8px rounded-rect) differ. Proposed schema:kind=day | month | year+state=default | today | selected | disabled(plusrange-middle | prev-nextexposed only whenkind=day). Collapses 10 sibling variants across two components into one component with two clean axes. A single nativePickerCellcomposable renders the correct dimensions, radius, and typography perkind. Family - Property — Add a
Stateaxis to match the day cell. Even before consolidation, promote the single-axisTypeschema intoType × State=Enabled | Disabled | Pressed | Focusedto align with the day cell's shape. At minimum, publishDisabledvariants for Default, Today, and Selected so the cell is usable in a date-range-restricted picker. Property - Rename — Fix the inner frame name. The inner auto-layout frame is named
Monthin all three variants, but the same component is instance-swapped as the year cell too. Rename toCellorLabel Containerso it reads true for both Month and Year views. Rename - Token — Rename
main/date-picker/day/*tomain/date-picker/cell/*. The shared token scope is nameddaybut also resolves for month and year cells. Rename (or split intomain/picker-cell/{day|month|year}/*if treatments intentionally diverge) so the token name honestly describes what it styles. Token - Token — Share a selection-emphasis token across Picker Cell kinds. Create
main/picker-cell/selection/*tokens that resolve to either "fill" or "ring" based onkind, and harmonise the Today ring stroke (1px here, 1.5px on the day cell) on a singleborder/picker-cell/todaytoken. Token - State — Add Pressed, Focused, and Today+Selected variants. Extend the state coverage with Pressed and Focused (needed for any custom wrapper rendering tap / keyboard affordances), and publish a decision variant for the common "today is also selected" case. State
- A11y — Flag the 32px height as below minimum target. The 100×32 cell is below WCAG 2.5.5 minimum 44×44 on the vertical axis. If a custom wrapper is ever built, pad the tap zone vertically so the hit area meets 44px. Document on the component so consumers don't ship the tight target outside the native picker context. A11y
- Docs — Mark as reference, not a production component. Given that both platforms render their own month/year views inside the native
DatePicker, this cell is a visual reference for the token-styled wrapper, not a component developers rebuild. Add a description banner and a_referenceprefix once the Picker Cell family unification lands. Docs
3 published variants on a single Type axis. All share a 100×32 frame, 8px corner radius, 4px label gap, and Primary/Label/Light/Small typography. Selected switches to Primary/Label/Small (bold).
The base month/year cell. Plain label on white, no ring or fill.
Today marker. 1px blue border, blue label. Note: thinner than the day cell's 1.5px ring.
Currently-selected month or year. Solid blue fill, white bold label. No Disabled form.
All cell colors are bound to tokens — but the tokens live under the main/date-picker/day/* scope even though this cell renders months and years. Selected uses main/date-picker/day/color/selected/*; Default/Today use main/date-picker/day/color/unselected/* plus the shared primary text and border tokens for the ring and label. The scope name is a mismatch — flagged as C3.
| Type | Role | Token | VALUE |
|---|---|---|---|
| Default | bg | main/date-picker/day/color/unselected/bg | #FFFFFF |
| Default | label | main/date-picker/day/color/unselected/label | #0A2757 |
| Today | bg | main/date-picker/day/color/unselected/bg | #FFFFFF |
| Today | border | border/color-border-primary | #005CE5 (1px) |
| Today | label | text/color-text-primary | #005CE5 |
| Selected | bg | main/date-picker/day/color/selected/bg | #005CE5 |
| Selected | label | main/date-picker/day/color/selected/label | #FFFFFF |
| Property | Value |
|---|---|
| Cell size | 100 × 32 |
| Corner radius | 8px (radius/radius-3) |
| Padding | 10px top, 8px bottom, 12px horizontal |
| Label gap | 4px (space/space-4) |
| Today border | 1px solid |
| Grid gap (month view 4×3) | per Date Picker - Group |
| Grid gap (year view 4×5) | per Date Picker - Group |
| Variant | Text Style | Font | Weight | Size | Line-height | Tracking |
|---|---|---|---|---|---|---|
| Default / Today | Primary/Label/Light/Small | Proxima Soft | Semibold (600) | 14px | 14px | 0.25px |
| Selected | Primary/Label/Small | Proxima Soft | Bold (700) | 14px | 14px | 0.25px |
The month/year cell is drawn by the native picker. SwiftUI DatePicker(.graphical) exposes a Month/Year wheel/list and Material 3 DatePicker exposes an input-style Year picker inside its header — neither accepts a custom cell slot. Token-tint the native picker to match the DS appearance — don't ship a custom EBMonthYearPickerItem composable.
iOS — SwiftUI
DatePicker( "Birth Month", selection: $date, displayedComponents: .date ) .datePickerStyle(.graphical) .tint(Color.ebBrand) // Selected month/year fill .accentColor(Color.ebBrand) // Today marker // Tap the month/year header in .graphical style to toggle // the month/year list view. No custom cell slot is exposed.
Android — Jetpack Compose (Material 3)
val state = rememberDatePickerState() DatePicker( state = state, colors = DatePickerDefaults.colors( selectedYearContainerColor = EBColors.brand, selectedYearContentColor = EBColors.onBrand, currentYearContentColor = EBColors.brand, // "Today" year yearContentColor = EBColors.onSurface ) ) // Material 3 renders each year cell; month navigation uses // arrow buttons in the default mode (no month-grid surface).
If the product absolutely needs a custom month/year grid (e.g. a birth-month picker separated from day selection), build a non-native grid and compose PickerCell(kind: .month | .year) inside it — but at that point you lose the native a11y and locale handling.
| Figma Property | SwiftUI Equivalent | Compose Equivalent | Notes |
|---|---|---|---|
| Type=Default | (default rendering) | yearContentColor / monthContentColor | Base month/year cell. Uses primary text token. |
| Type=Today | .accentColor / automatic current-year marker | currentYearContentColor | Native pickers detect "today" from Calendar.current; only the emphasis color is supplied. |
| Type=Selected | .tint (via selection binding) | selectedYearContainerColor / selectedYearContentColor | Solid fill. Selection is derived from the bound Date. |
| (missing) Disabled | in: Date... range parameter | yearRange / selectableDates | Enforced via the allowable range — picker dims out-of-range months/years automatically. Figma has no token for this state. |
| (missing) Pressed / Focused | — | — | Native pickers supply tap feedback and focus ring automatically. Figma needs variants for any custom wrapper. |
| Requirement | iOS | Android |
|---|---|---|
| Touch target (44 × 44 min) | Figma cell is 100 × 32 — native picker extends vertical hit area | Figma cell is 100 × 32 — native picker extends vertical hit area |
| Screen reader label | VoiceOver: "March, Month" / "2026, Year" (from DatePicker) | TalkBack: "March" / "2026" with "selected" state |
| Selected announcement | "Selected" trait added automatically | "Selected" state added automatically |
| Focus ring | System focus ring on iPad / hw keyboard | System focus indicator on D-Pad / hw keyboard |
| Disabled announcement | "Dimmed" trait when outside in: range | "Disabled" state when outside selectableDates |
| Dynamic Type / font scaling | Automatic | Automatic |
Do
Treat this cell as a visual reference for how the native picker's month and year views should look in GCash theme — colors, ring thickness, radius, and label weight.
Don't
Don't ship a standalone EBMonthYearPickerItem composable. Native pickers render their own month/year cells; a custom cell composable has no slot to plug into.
Do
If you genuinely need a custom month/year grid, merge with Date Picker - Item into a unified PickerCell(kind:, state:) and compose it inside a custom grid.
Don't
Don't maintain Date Picker - Item and Month and Year Picker - Item as siblings — they're the same primitive at different sizes.
Do
Rely on the native picker for locale-aware month names, era handling, VoiceOver / TalkBack, and minDate/maxDate enforcement on both the month and year axes.
Don't
Don't draw disabled months/years manually. Pass an in: range (SwiftUI) or selectableDates (Compose) and let the platform dim them.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Needs Fix | Sibling-duplication with Date Picker - Item. Inner frame named Month even when used as the year cell. |
| C2 | Variant & Property Naming | Needs Fix | Narrower state axis than the sibling day cell — single Type axis with no State. Cells in the same family should share state shape. |
| C3 | Token Coverage | Partial | All values are token-bound, but tokens are scoped main/date-picker/day/* on a cell that renders months and years. Rename to main/date-picker/cell/* or split per kind. |
| C4 | Native Mappability | Not Applicable / Rework | Native pickers own the month/year view and don't accept a custom cell view. Reference-only unless merged into a custom PickerCell. |
| C5 | Interaction State Coverage | Needs Fix | No Disabled, no Pressed, no Focused, no Today + Selected. Single-axis variant matrix with no State dimension. |
| C6 | Asset & Icon Quality | Partial | No raster assets. But Today ring is 1px here vs 1.5px on the sibling day cell — should be one shared token. |
| C7 | Code Connect Linkability | Not Mapped | Blocked by C4 (native owns it) and by the pending Picker Cell family unification. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Needs Fix | Add a State axis; align with day cell shape or merge into PickerCell. |
| Family unification | Pending | Merge with Date Picker - Item into PickerCell with kind: day | month | year. |
| Token scope | Needs Fix | Rename main/date-picker/day/* to main/date-picker/cell/* (or split per kind). |
| Native component file | Pending | No standalone composable — native DatePicker renders month/year cells. Only materialize EBPickerCell if a custom grid is ever built. |
| Recommendation | Consolidate | Merge into PickerCell, mark as reference spec for the native picker's month/year view. |
3 variants on a single Type axis. The sibling day cell has 7 variants on Type × State — this cell lacks the State dimension entirely.
| Type | Dimensions | Emphasis | Node ID |
|---|---|---|---|
| Default | 100 × 32 | none | 18414:5851 |
| Today | 100 × 32 | 1px blue border | 18414:5852 |
| Selected | 100 × 32 | solid blue fill, white bold label | 18414:5853 |
Missing: Disabled, Pressed, Focused, Hover, Today + Selected — no State axis published.
Type axis. 100×32 rounded-rect cell with token-bound colors, spacing, radius, and typography. DocumentedPicker Cell with kind: day | month | year. OpenMonth across all variants — Same component instance-swapped as both month and year cell; layer name is inaccurate for year instances. OpenType axis only. No State dimension, so Disabled / Pressed / Focused are all missing. Cells in the same family should share state shape. Openmain/date-picker/day/* used for non-day cell — Bg and label tokens are named day but style months and years. Rename to main/date-picker/cell/* or split per kind. OpenDatePicker renders its own month/year cells on both platforms. Reference-only unless a custom grid is built. OpenState axis published at all. The common "today is also selected" combination is undefined. OpenA directional floating tip — header, description, close X, and a pointer on any of four sides. Ships as 4 variants across a single pointer axis (top, right, bottom, left). Despite the Onboarding - prefix, the component ships no step indicator and no CTAs — it is functionally Tooltip V2's Header + Description + dismiss slice with the CTA and icon axes removed. One of three Tooltip siblings in the DS.
Tooltip with placement: .top | .right | .bottom | .left (the only axis this sibling ships), appearance: .default | .onboarding | .translucent, hasDismiss, and an optional content/CTA body. Replace the 4 raster pointers with one vector and the raw close image with an icon/close instance. Once merged, rename the sibling that truly supports walkthroughs (with step indicator + Next/Skip) to carry the .onboarding appearance — or drop the name entirely.Three sibling components in the DS do roughly the same job. Onboarding - Tooltip is the thinnest slice — a one-axis component that exists only because pointer-direction lives outside Tooltip V2's variant matrix.
| Component | Node | Variants | What's different | Proposed role |
|---|---|---|---|---|
| Tooltip V2 | 70:14908 | 8 | Header/Description/Icon/CTA presence axes. Pointer as 4 booleans outside the matrix. | appearance: .default + content slots |
| Onboarding - Tooltip (this) | 51:17066 | 4 | One pointer enum. Header + description + close only — no icon, no CTA, despite the name. | placement axis on the unified Tooltip |
| Tooltip Blurred and Transparent | 49:335349 | — | Translucent surface with backdrop blur — used over photographic / high-contrast content. | appearance: .translucent |
Target architecture: one Tooltip with an appearance enum, one placement enum, optional hasArrow / hasDismiss, and a single content slot. This sibling collapses into a subset: Tooltip(appearance: .default, placement:_, title:_, body:_, hasDismiss: true).
A dismissible tip anchored to a feature element — commonly rendered during first-time user education, feature discovery, and coach-mark flows.
Toggle the pointer direction. Header, description, and close are fixed in this sibling — there is no way to hide the title or show a CTA without switching to Tooltip V2.
main/nudge/* + space/* tokens — shares the collection with Tooltip V2. But the pointer arrow is 4 raster copies of one shape, and the close is an image asset rather than a DS icon instance. C6| Behavior | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Show / hide | Yes | Yes | Not annotated | Expected: fade + slight scale-in anchored on the pointer side. |
| Tap close (X) | Yes | Yes | Close layer | Dismiss icon is present but not wired to an interactive property. Close is an image asset, not a button instance. |
| Tap outside | Implicit | Implicit | Not defined | Standard tooltip contract — tap-outside dismisses. Should be documented on the component. |
| Primary CTA | Missing | Missing | Not built | Despite the "Onboarding" name, no CTA is provided. Consumers must switch to Tooltip V2 for any advance/skip control. |
| Pressed / Focused on close | Missing | Missing | Not built | Close is a raw image; no pressed / focused treatment and no hit target metadata. |
- Name implies walkthrough content that isn't shipped.
Onboarding - Tooltiphas no step indicator, no Next/Skip/Back CTAs, no progress dots, no illustration slot — it's just header + description + close. The prefix misleads consumers. Either rename toTooltip / Placementand fold into the unified Tooltip, or add the actual onboarding axes (step indicator, Next/Skip) before keeping the name. C1 · Layer Structure & Naming - Three sibling Tooltip components for one primitive.
Tooltip V2(70:14908), this component (51:17066), andTooltip Blurred and Transparent(49:335349) all model the same floating popover with slightly different shapes. Collapse into oneTooltipwithappearance+placement. C1 · Layer Structure & Naming - Pointer schema disagrees with sibling. Onboarding - Tooltip models pointer direction as one
pointerenum (correct), but Tooltip V2 models the same concept as 4 independent booleans. Siblings in the same family should never disagree on the shape of a shared axis. C2 · Variant & Property Naming - Pointer triangle is a raster asset. 4 separate image fills (
imgPointer,imgPointer1,imgPointer2,imgPointer3) for what should be one vector shape rotated per direction. Replace with a single vector triangle; rotation handled by theplacementenum. C6 · Asset & Icon Quality - Close (X) is an image asset, not a DS icon instance. The dismiss control uses
imgShapeFullinside a generic "Close" frame rather than an instance of the DS'sicon/close. Hides the icon from a11y labeling and token updates, blocks Code Connect from seeing it as a real control. C1 · Layer Structure & Naming - No body or CTA slot. There is no way to add a leading icon, an illustration, a step indicator, or a CTA. Any consumer that needs those must abandon this component for Tooltip V2 — defeating the point of a dedicated "Onboarding" sibling. C4 · Native Mappability
- No dismiss / show states modeled. Close button is decorative; no Pressed / Focused state on the dismiss control. No appearing / dismissing lifecycle documented. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked on the family consolidation. Mapping today's 3-sibling shape to native would cement the wrong schema. C7 · Code Connect Linkability
- Fold the 3 Tooltip siblings into one component. New schema:
placement: .top | .right | .bottom | .left(this sibling's only axis),appearance: .default | .onboarding | .translucent,hasArrow: Bool,hasDismiss: Bool,cta: .none | .primary | .primaryAndSecondary, and aleadingslot. This sibling's 4 variants collapse into theplacementaxis of the.defaultappearance — no dedicated component needed. Family - Rename or retire the "Onboarding -" prefix. The prefix should either be reserved for a sibling that actually ships onboarding affordances (step indicator, Next/Skip, Back) — in which case this component doesn't qualify — or dropped entirely in favor of an
appearance: .onboardingenum on the unified Tooltip. Today's name promises content it doesn't deliver. Rename - Replace the 4 raster pointers with one vector triangle. Today there are 4 separate rasters (top/right/bottom/left). One vector shape, rotated per
placement, fills the same role with zero asset burden, scales cleanly, and picks up surface + border token updates automatically. Asset - Instance-swap the close to
icon/close. Use the canonical DS close-icon instance rather than an inlineimgShapeFull. Gets you token-driven color, a11y labeling, and press-state handling for free. Composition - Adopt a content slot for body + optional CTA. The current closed shape (header + description + close, nothing else) is the reason this sibling exists. Adopt the unified Tooltip's content slot and this component disappears into the single source of truth. Slot
- Add Pressed / Focused on the close control. Once the close becomes an icon-button instance rather than an image, states follow the canonical Button/Icon pattern. State
- Document the dismiss contract + lifecycle. Add a description on the component: "Tap the close X or tap outside to dismiss. Tooltip appears with fade + slight scale from the pointer anchor; dismisses with reverse." Close the gap between designer intent and dev implementation. Docs
4 variants on a single pointer axis — top / right / bottom / left. Content is fixed: header + description + close. Surface is 336 × variable height; pointer adds 12 px on the anchored side.
Pointer triangle above the bubble, surface below. 336 × 90 (pointer adds 12 px on top). Used when the tip describes an element positioned above the tooltip in the flow.
Pointer triangle below the bubble. 336 × 89. Most common placement for tips anchored to an icon in a toolbar or nav bar.
Pointer triangle on the left edge. 348 × 78 (pointer adds 12 px on the left). Used for tips describing a leading-edge control.
Pointer triangle on the right edge. 348 × 78 (pointer adds 12 px on the right). Used for tips describing a trailing-edge control.
| Role | Token | Default |
|---|---|---|
| Surface | main/nudge/color/primary/bg | #FFFFFF |
| Border | main/nudge/color/primary/border | #E5EBF4 |
| Header label | main/nudge/color/primary/label | #0A2757 |
| Description | main/nudge/color/primary/description | #6780A9 |
| Close icon | main/nudge/color/primary/icon-close | #0A2757 |
| Shadow | elevation/app/shadow-low | #020E22 · 6 % |
| Pointer triangle | — | raster (4 images) |
No Pressed / Disabled states modeled at the tooltip level. Pointer triangle should inherit the surface + border tokens once it becomes a vector.
| Property | Token | Value |
|---|---|---|
| Surface width | — | 336 px |
| Surface corner radius | radius/radius-2 | 6 px |
| Surface border | main/nudge/color/primary/border | 1 px solid |
| Surface padding | space/space-16 | 16 px all sides |
| Header row gap (title ↔ close) | — | 24 px |
| Title ↔ description gap | space/space-4 | 4 px |
| Close hit-area padding | — | 3 px (18 × 18 icon, ~24 × 24 hit) |
| Pointer width / height | — | 24 × 12 (raster image) |
| Shadow offset / blur | elevation/app/shadow-low | 0 / 0 / 4 / 0 |
Dimensions change slightly between top/bottom (336 × 89–90) and left/right (348 × 78) because the pointer adds 12 px to the anchored edge.
| Element | DS text style | Spec |
|---|---|---|
| Header | Primary/Headlines/Block | Proxima Soft Bold · 18 / 23 · +0.25 |
| Description | Secondary/Bold/Caption | BarkAda Semibold · 12 / 18 · +0 |
BarkAda (secondary) is used for the description — custom-font standing action item applies.
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:tooltip:1.0.0") }
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
| Onboarding - Tooltip (this) | Tooltip · appearance: .default | EBTooltip().ebAppearance(.default) | EBTooltip(appearance=.Default) |
| pointer: top / right / bottom / left | placement: .top / .right / .bottom / .left | arrowEdge: Edge | anchorPosition: EBTooltipAnchor |
| header (baked text) | title: String? | title: String? | title: String?=null |
| description (baked text) | body: String? (or content slot) | body: String? | body: String?=null |
| close (image asset, always shown) | hasDismiss: Bool | dismissible: Bool | dismissible: Boolean=true |
| (not modeled) | cta: .none / .primary / .pair | primary / secondary: TooltipAction? | primaryAction / secondaryAction: TooltipAction? |
| (not modeled) | leading (Slot) | @ViewBuilder leading | leading: @Composable () -> Unit |
| (not modeled) | onDismiss | onDismiss: () -> Void | onDismiss: () -> Unit |
// Equivalent of today's "Onboarding - Tooltip · pointer=bottom" EBTooltip( title: "Quick transfers", body: "Tap here to send money to recent contacts.", placement: .bottom ) .onDismiss { showTip = false } // pointer=right · same appearance, placement flipped EBTooltip( title: "Your balance", body: "Tap to reveal or hide.", placement: .right )
// Equivalent of today's "Onboarding - Tooltip · pointer=bottom" EBTooltip( title = "Quick transfers", body = "Tap here to send money to recent contacts.", placement = EBTooltipPlacement.Bottom, onDismiss = { showTip = false } ) // pointer=right EBTooltip( title = "Your balance", body = "Tap to reveal or hide.", placement = EBTooltipPlacement.Right )
| Requirement | iOS | Android |
|---|---|---|
| Role + focus | Announce as .accessibilityAddTraits(.isModal) when dismissible; VoiceOver moves focus to the tooltip on appear. | semantics { role=Role.Popup }; TalkBack focuses the tooltip container on appear. |
| Close control | Wrap close as a Button with accessibilityLabel "Dismiss tip" and 44×44 hit target (current 24 × 24 is below minimum). | IconButton with contentDescription="Dismiss tip"; 48×48 dp minimum (current hit area is ~24 × 24 dp). |
| Dismiss-outside | Do not auto-dismiss while VoiceOver is active — hold until user explicitly moves on. | Do not auto-dismiss while TalkBack is active. |
| Reduce motion | Respect UIAccessibility.isReduceMotionEnabled — fade only, skip scale-in. | Respect Settings.Global.TRANSITION_ANIMATION_SCALE — fade only when motion reduced. |
| Combined label | Read title + body + "Dismiss" as one phrase; avoid reading pointer. | mergeDescendants=true on the container. |
- Use for short, dismissible tips anchored to a specific element.
- Anchor the pointer at the target so users associate the tip with its referent.
- Write one short title and one short body — if you need two, promote to a richer component.
- Don't use this component for real onboarding walkthroughs — it has no step indicator or Next/Skip. Use the unified Tooltip with
appearance: .onboarding(when the schema lands) or a Modal with an explicit flow. - Don't use for error / status messaging — use Alert.
- Don't stack multiple tooltips simultaneously — one focal point per screen.
- Don't hand-edit the pointer — pick a direction with the
pointerproperty.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | "Onboarding -" prefix misleads — component ships no walkthrough content. 3 sibling Tooltip components for one primitive. Close uses a raw image inside a generic "Close" frame. |
| C2 | Variant & Property Naming | Refine | Pointer is correctly modeled as one enum here (cleaner than Tooltip V2's 4 booleans) — but siblings should agree on the shape of the shared axis. |
| C3 | Token Coverage | Ready | Surface, border, label, description, close icon, shadow all bound to main/nudge/* / elevation/*. Spacing via space/*. Radius via radius/radius-2. |
| C4 | Native Mappability | Rework | Closed shape — no slot for body content or CTA. Maps cleanly to native only after consolidation into the unified Tooltip with content slot + cta axis. |
| C5 | Interaction State Coverage | Rework | No Pressed / Focused on close. No appearing / dismissing lifecycle annotated. Close isn't wired to a dismiss property. |
| C6 | Asset & Icon Quality | Rework | Pointer is 4 raster images (one per direction). Close is an image asset (imgShapeFull) rather than an icon/close instance. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on consolidation. Mapping today's sibling component would cement the wrong schema. |
One axis: pointer (4 values)=4 variants. Content (header / description / close) is fixed across all variants.
| # | Pointer | Dimensions | Pointer asset | Node |
|---|---|---|---|---|
| 1 | top | 336 × 90 | imgPointer | 51:17065 |
| 2 | bottom | 336 × 89 | imgPointer1 | 51:17063 |
| 3 | left | 348 × 78 | imgPointer2 | 51:17062 |
| 4 | right | 348 × 78 | imgPointer3 | 51:17064 |
Four separate raster images for what should be one vector shape rotated by the pointer value.
appearance: .default with the placement axis. The "Onboarding -" prefix is misleading (no walkthrough content ships). Openappearance + placement. Openicon/close. Vector + icon instance. Openmain/nudge/* and elevation/*. Spacing via space/*. NotedA translucent scrim that dims background content behind modals, bottom sheets, dialogs, and side drawers. One inner layer dim filled with bg/color-bg-overlay-strong (#020E228F · 56% opacity). Used to signal "the screen is in a modal state" and to surface the floating content above it.
.presentationBackground, Compose Scrim). Before linking, resize to Fill parent, decide whether a standard-strength variant is needed, and annotate the tap-to-dismiss contract.Overlay sits between page content and a floating surface (bottom sheet, dialog, drawer). It dims the content below to focus attention on the surface above.
The overlay's single visual state — a flat, token-bound dim layer. There are no interactive properties on the component itself; dismiss and show/hide behaviors live in the surface it sits behind.
overlay-strong suggesting a standard-strength companion, but only one strength is exposed as a component. Naming implies a set of two.| Behavior | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Show / hide | Yes | Yes | Not defined | Fades with the presentation transition of its owning surface. |
| Tap to dismiss | Yes | Yes | Not annotated | Contract: tap-scrim dismisses unless surface is marked modal. |
| Scroll lock | Implicit | Implicit | Handled by surface | Owning sheet/dialog locks background scroll on mount. |
| Focus / a11y | Yes | Yes | Implicit | Scrim itself is not focusable — owning surface traps focus. |
- No strength variants. Only
bg/color-bg-overlay-strong(56%) is exposed. Token name implies a standard-strength (32%) counterpart that isn't surfaced. Consider aStrength=Standard | Strongproperty. C2 · Variant & Property Naming - Fixed frame size 360×640. Forces consumers to resize the instance every time. Should use auto-layout Fill on both axes so it scales to any parent. C4 · Native Mappability
- Tap-to-dismiss contract not annotated. The standard behavior (tap-scrim dismisses, unless the surface is modal) should be documented on the component so designers and devs agree on the contract. C5 · Interaction State Coverage
- No Code Connect mapping. Trivial once the size and variant questions above are settled. C7 · Code Connect Linkability
- Set frame to Fill parent. Change both width and height from fixed to Fill so Overlay adapts to any container (phone, tablet, custom sheet). Matches how native
Scrimbehaves. Composition - Decide on strength variants. Two paths: (a) add
Strength=Standard (32%) | Strong (56%)property and bind to two tokens, matching Material 3; or (b) keep a single 56% strength and rename the token fromoverlay-strongtooverlayso the name stops implying a second variant exists. Property - Annotate the dismiss contract. Add a description on the component: "Tap-outside dismisses the surface above, unless the surface is modal (requires explicit action)." This closes the gap between designer intent and developer implementation. Docs
- Document layer order. Add a short note:
Content → Overlay → Floating surface (Sheet / Dialog / Drawer). Prevents teams from accidentally putting the floating surface under the scrim. Docs - Naming note (informational). Other DS call this primitive Scrim (Material 3), Backdrop (Fluent / Polaris), Mask (Ant), Blanket (Atlassian), Underlay (Spectrum). Team keeps Overlay — worth documenting here so cross-DS references aren't confusing. Docs
47:329691The only current variant — a flat translucent fill at 56% opacity of the overlay color. Drop it behind any sheet, dialog, or drawer.
Overlay1dim| ROLE | TOKEN | VALUE |
|---|---|---|
| Dim fill | bg/color-bg-overlay-strong | #020E228F |
8F). Base color #020E22 is the DS neutral-darkest; alpha does the dimming.360640Fill × Fill0// Direct placement — recommended for full-screen overlays EBOverlay() .ignoresSafeArea() // Behind a sheet surface .sheet(isPresented: $showSheet) { EBBottomSheet { ... } .presentationBackground(.ebOverlay()) }
// Direct placement — recommended for full-screen overlays EBOverlay(modifier = Modifier.fillMaxSize()) // Behind a sheet surface — Material 3 Scrim is the primitive ModalBottomSheet( onDismissRequest = { showSheet = false }, scrimColor = EBTokens.bgOverlayStrong ) { ... }
// Swift Package Manager .package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
// build.gradle.kts implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBOverlay
| Figma property | SwiftUI | Compose |
|---|---|---|
| None exposed | EBOverlay() — no parameters today. | EBOverlay(modifier: Modifier=Modifier) |
| (proposed) Strength | .ebStrength(.standard | .strong) | strength=EBOverlayStrength.Standard | Strong |
| (proposed) onDismiss | .onTapGesture { onDismiss() } | Modifier.clickable { onDismiss() } |
ios/Components/Overlay/EBOverlay.swiftandroid/components/overlay/EBOverlay.kt
// 1 · Standalone overlay (e.g. loading blocker) ZStack { PageContent() if isBusy { EBOverlay() .ignoresSafeArea() .onTapGesture { } // blocks taps but doesn't dismiss } } // 2 · Behind a bottom sheet — with dismiss .sheet(isPresented: $showSheet) { EBBottomSheet(onDismiss: { showSheet = false }) { ... } .presentationBackground(.ebOverlay()) .presentationDetents([.medium, .large]) } // 3 · Non-dismissible modal (requires explicit action) .fullScreenCover(isPresented: $showDialog) { EBDialog { ... } .presentationBackground(.ebOverlay()) .interactiveDismissDisabled(true) }
// 1 · Standalone overlay (e.g. loading blocker) Box(Modifier.fillMaxSize()) { PageContent() if (isBusy) { EBOverlay(Modifier.fillMaxSize()) } } // 2 · Behind a bottom sheet — Material 3 Scrim is the primitive ModalBottomSheet( onDismissRequest = { showSheet = false }, scrimColor = EBTokens.bgOverlayStrong ) { EBBottomSheet { ... } } // 3 · Non-dismissible dialog Dialog( onDismissRequest = { }, properties = DialogProperties(dismissOnClickOutside = false) ) { EBOverlay(Modifier.fillMaxSize()) EBDialog { ... } }
| Requirement | iOS | Android |
|---|---|---|
| Not focusable itself | Overlay is decorative. Do not expose it to VoiceOver — focus belongs to the surface above. | Use Modifier.clearAndSetSemantics { } on the scrim so TalkBack ignores it. |
| Modal announcement | The sheet/dialog above owns .accessibilityAddTraits(.isModal). | The sheet/dialog above owns semantics { paneTitle="..." } and modal behavior. |
| Tap-to-dismiss target | Full-screen tap area counts as the dismiss hit region — comfortably above the 44×44pt target. | Full-screen tap area — comfortably above the 48×48dp target. |
| Reduce transparency | Respect UIAccessibility.isReduceTransparencyEnabled — fall back to an opaque dim color if true. | Respect Settings.Global.TRANSITION_ANIMATION_SCALE and high-contrast mode — increase opacity or swap to solid dim. |
- Pair Overlay with a floating surface (sheet, dialog, drawer) to create a focused modal moment.
- Set the overlay to Fill parent so it covers whatever container it lives in.
- Document the dismiss contract — tap-outside dismisses, unless the surface is marked modal.
- Animate the overlay's opacity alongside the floating surface so they feel like one motion.
- Don't use Overlay as a page background or to visually tint content — use a surface token instead.
- Don't stack multiple overlays on top of each other; promote to a full-screen experience.
- Don't forget to block background taps — tapping through the scrim is always a bug.
- Don't hardcode
#020E228Fin code — bind tobg/color-bg-overlay-strong.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Inner layer named dim — semantic and accurate. |
| C2 | Variant & Property Naming | Needs Refinement | No properties exposed. Token name overlay-strong implies a standard-strength counterpart that isn't available. Either add a Strength property or drop the -strong qualifier from the token. |
| C3 | Token Coverage | Ready | Fill bound to bg/color-bg-overlay-strong. |
| C4 | Native Mappability | Needs Refinement | Maps cleanly to SwiftUI .presentationBackground and Compose Scrim, but fixed 360×640 frame needs to become Fill × Fill before linking. |
| C5 | Interaction State Coverage | Needs Refinement | Tap-to-dismiss behavior is implicit — should be annotated on the component as a documented contract. |
| C6 | Asset & Icon Quality | N/A | No assets or icons. |
| C7 | Code Connect Linkability | Not Mapped | No Code Connect mapping yet. Trivial once sizing and strength are finalized. |
Single variant — no property matrix.
| # | Name | Node | Dimensions | Fill | Notes |
|---|---|---|---|---|---|
| 1 | Overlay / Strong | 47:329691 | 360 × 640 | bg/color-bg-overlay-strong | Default state — the single shipped variant. |
strong (56%) exposed, token name implies a standard counterpart. OpenA linear task-progress indicator — a brand-blue fill over a light-blue track, used to show how far along a multi-step task is (KYC, file upload, form wizard). Today the component ships 11 variants for percentage=0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, each rendered by swapping a pair of pre-sized raster PNGs (back + front). There is no continuous value, no indeterminate animation, and no success / error state.
progress valueprogress: Float (0–1), state: determinate | indeterminate | success | error. Replace the raster back / front layers with vector strokes bound to main/progress-bar/color/border-track and main/progress-bar/color/border. Native side maps 1:1 to ProgressView / LinearProgressIndicator.Progress Bar appears above or below task content to show completion — KYC steps, file upload progress, multi-step form wizards.
Drag the slider to set progress. Note that Figma today only supports the ten 10%-step jumps — a real implementation would smoothly interpolate. Flip state to preview the proposed indeterminate, success, and error variants.
back / front image layers instead of shape layers. Ships 11 PNGs worth of assets for what should be two rectangles.percentage enum with 11 variants — cannot express 37%, 62%, or any non-decile value. Every other slider-style value in the DS is continuous.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Determinate | Yes | Yes | percentage=0…100 | Fill grows left-to-right in 10% steps today. Should be continuous (0–1). |
| Indeterminate | Missing | Missing | Not modeled | Not modeled in Figma. Native primitives support it out of the box — add a variant so designers can spec it. |
| Success | Missing | Missing | Not modeled | Green fill at 100%. Used to confirm the task completed cleanly (file uploaded, verification passed). |
| Error | Missing | Missing | Not modeled | Red fill at the point of failure. Used when the task fails mid-progress (upload retry, network dropped). |
| Buffered | N/A | Optional | Not modeled | Compose supports a secondary buffered fill. Optional — only add if media streaming is a real use case. |
- Progress is an enum of 10% steps, not a value. The Figma component exposes
percentageas 11 discrete options (0, 10, 20, …, 100). Consumers can't spec 37% or animate smoothly — they must pick the closest variant. Every scalar value in the DS should be continuous. C2 · Variant & Property Naming - Track and fill are raster PNGs. The
backandfrontlayers are<img>assets, not shape layers. Blocks token-driven theming, breaks at non-1× resolutions, and ships 11 PNG pairs for what should be two rectangles. C6 · Asset & Icon Quality - Layer names are structural (
back/front), not semantic. Should beTrackandFillto match native parlance and make the inspector readable for handoff. C1 · Layer Structure & Naming - No indeterminate, success, or error state modeled. Native
ProgressViewandLinearProgressIndicatorboth support indeterminate natively; product flows (KYC failure, upload retry) need success / error color states. Today Figma has only the determinate-blue variants. C5 · Interaction State Coverage - Property name
percentagereads as a unit, not a ratio. Native APIs useprogressas a 0–1Float. Renaming toprogress(with a 0–1 range, or 0–100 if kept as integer) aligns the DS with iOS / Compose conventions. C2 · Variant & Property Naming - Code Connect mappings not registered. Cannot land until the progress value is parameterized and the raster layers are replaced with token-bound shapes. C7 · Code Connect Linkability
- Collapse 11 percentage variants into a single component with a continuous
progressvalue. Delete thepercentage=0 | 10 | … | 100enum. Exposeprogress: Float(0–1). In Figma, drive the fill width by layout — either a single variant with a layer the designer resizes, or a published component that lives as a native primitive on the dev side. Variant math drops from 11 to 1 (+ state). Property - Replace raster
back/frontimages with token-bound rectangles. Two filled rectangles (or stroked lines) bound tomain/progress-bar/color/border-trackandmain/progress-bar/color/border. Same visual output, theme-able, resolution-independent, no PNG pairs to ship. Asset - Rename layers
back→Trackandfront→Fill. Matches native terminology (track/tintin SwiftUI,trackColor/colorin Compose) and reads better in the inspector. Rename - Add
statevariant: determinate / indeterminate / success / error. Indeterminate is a looping animation designers should be able to spec. Success (positive green) and error (negative red) cover KYC / upload result states. Token references:main/progress-bar/color/successandmain/progress-bar/color/error— add to the collection. State - Reuse existing semantic color tokens for success / error. Don't mint new hex values. Align with Alert / Badge semantic colors (
text/positive,text/negative) so the progress fill reads the same as the rest of the system. Token - Evaluate whether a bespoke
EBProgressBaris needed at all. SwiftUIProgressView(value:)and ComposeLinearProgressIndicatorare 1:1 matches for this component. If the only custom requirement is token-bound colors, a lightweight theming wrapper suffices; otherwise use the native primitive directly. Document either way. Composition - Make the component width-flexible. Today it's locked to 312 px with 2-px horizontal padding. Spec as fill-container so a designer can drop it into any column width. Property
- Document accessibility expectations.
role="progressbar",aria-valuenow/aria-valuemin/aria-valuemaxfor determinate; announce a localized label for indeterminate ("Loading…"). Both native APIs handle this automatically, but the web/hybrid consumer needs the spec. A11y
27:64947 through 27:64985Linear fill with a light-blue track and a brand-blue fill. The 11 Figma variants step through percentage=0, 10, 20, …, 100 — each variant swaps a pre-sized raster pair. The target implementation renders a single component with a continuous progress value.
0 | 10 | 20 | … | 100raster <img>raster <img>312 (fixed)| ROLE | TOKEN | VALUE |
|---|---|---|
| Track | main/progress-bar/color/border-track | #D2E5FF |
| Fill (determinate) | main/progress-bar/color/border | #005CE5 |
| Fill (success) — proposed | main/progress-bar/color/positive | #12AF80 |
| Fill (error) — proposed | main/progress-bar/color/negative | #D81E1E |
31223084 (raster-baked)0 (radius/radius-0)—externalProgress Bar maps to native primitives (ProgressView on iOS, LinearProgressIndicator on Android). A thin DS wrapper is only needed to bind the branded colors.
.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBProgressBar
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
percentage: 0 | 10 | … | 100 (enum) | progress: Float (0–1) | value: Double | progress: () -> Float |
| (not modeled) | state: determinate | indeterminate | success | error | ProgressView() (indeterminate) / .tint(.green / .red) | LinearProgressIndicator() (indeterminate) / color=Color.Green / Red |
(raster back) | Track (vector, border-track) | .progressViewStyle(.linear) | trackColor=EBColors.progressTrack |
(raster front) | Fill (vector, border) | .tint(EBColors.progressFill) | color=EBColors.progressFill |
| 312 fixed | fill-container | .frame(maxWidth: .infinity) | modifier=Modifier.fillMaxWidth() |
ios/Components/ProgressBar/EBProgressBar.swift— thin wrapper aroundProgressViewwith DS colorsandroid/components/progressbar/EBProgressBar.kt— thin wrapper aroundLinearProgressIndicatorwith DS colors- Alternative: use native primitives directly with a token-bound
.tint/color. No wrapper required.
// Determinate — continuous value EBProgressBar(progress: 0.45) // Indeterminate — looping animation EBProgressBar(state: .indeterminate) // Success — 100% in positive green EBProgressBar(progress: 1.0, state: .success) // Error — fails mid-progress in negative red EBProgressBar(progress: 0.6, state: .error) // Or use the native primitive directly ProgressView(value: 0.45) .progressViewStyle(.linear) .tint(EBColors.progressFill)
// Determinate — continuous value EBProgressBar(progress = { 0.45f }) // Indeterminate — looping animation EBProgressBar(state = EBProgressBarState.Indeterminate) // Success — 100% in positive green EBProgressBar(progress = { 1.0f }, state = EBProgressBarState.Success) // Error — fails mid-progress in negative red EBProgressBar(progress = { 0.6f }, state = EBProgressBarState.Error) // Or use the native primitive directly LinearProgressIndicator( progress = { 0.45f }, color = EBColors.progressFill, trackColor = EBColors.progressTrack, modifier = Modifier.fillMaxWidth() )
| Requirement | iOS | Android |
|---|---|---|
| Progress role | ProgressView exposes the progressbar trait automatically. Set .accessibilityLabel("Verification progress") for context. | LinearProgressIndicator emits ProgressBarInfo via semantics automatically. Set Modifier.semantics { contentDescription="Verification progress" }. |
| Value announcement | VoiceOver reads the current value (0–100%). For non-percentage ranges, use .accessibilityValue("\(step) of \(total)"). | TalkBack reads the progress fraction. For custom phrasing, set stateDescription. |
| Indeterminate | Announce a localized label ("Loading…"). Avoid announcing a fake percentage. | Same — LinearProgressIndicator() with no progress lambda handles this natively. |
| Contrast | Fill #005CE5 on track #D2E5FF=3.1:1 — passes 3:1 for non-text graphics (WCAG 1.4.11). OK. | Same ratio. |
| Reduced motion | Indeterminate animation should honor UIAccessibility.isReduceMotionEnabled. Native ProgressView does this automatically. | Native LinearProgressIndicator already respects Animator.getDurationScale. |
- Use determinate when the total duration / step count is known (KYC wizard, upload with known size).
- Use indeterminate when the total is unknown (network requests, streaming data).
- Pair with a label explaining what's progressing — "Verifying your ID…", "Uploading 3 of 5 files".
- Stretch to fill the column width so it reads as a meter, not a pill.
- Don't use Progress Bar for rating or slider input — this is display-only, not interactive.
- Don't ship 10-step variants in product code — use a continuous 0–1 value.
- Don't place raster images inside to achieve the fill — use the native primitive.
- Don't animate from 0% to 100% on mount for decorative reasons — the value should reflect real progress.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Needs Refinement | Rename back → Track, front → Fill. Otherwise structurally clean. |
| C2 | Variant & Property Naming | Rework | percentage is a discrete enum; should be a continuous progress: Float (0–1). |
| C3 | Token Coverage | Ready | Track + fill colors bound to main/progress-bar/color/*. Add success / error tokens once states are introduced. |
| C4 | Native Mappability | Ready | Maps 1:1 to ProgressView(value:) / LinearProgressIndicator. No custom gesture or web-only patterns. |
| C5 | Interaction State Coverage | Rework | Missing indeterminate, success, error. Only determinate (in 10% steps) modeled today. |
| C6 | Asset & Icon Quality | Rework | Track + fill are raster <img>. Replace with token-bound rectangles or vector strokes. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until progress is parameterized and rasters replaced. |
A single percentage axis with 11 discrete values (0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100)=11 variants. The target architecture collapses this axis into a continuous progress value — variant count drops to 1 (plus the new state axis for indeterminate / success / error).
| # | Node | percentage | Fill width | Layer pair |
|---|---|---|---|---|
| 1 | 27:64947 | 0 | 0 / 308 | back only (no front rendered) |
| 2 | 27:64949 | 10 | ~31 / 308 | back + front raster |
| 3 | 27:64953 | 20 | ~62 / 308 | back + front raster |
| 4 | 27:64957 | 30 | ~92 / 308 | back + front raster |
| 5 | 27:64961 | 40 | ~123 / 308 | back + front raster |
| 6 | 27:64965 | 50 | ~154 / 308 | back + front raster |
| 7 | 27:64969 | 60 | ~185 / 308 | back + front raster |
| 8 | 27:64973 | 70 | ~216 / 308 | back + front raster |
| 9 | 27:64977 | 80 | ~246 / 308 | back + front raster |
| 10 | 27:64981 | 90 | ~277 / 308 | back + front raster |
| 11 | 27:64985 | 100 | 308 / 308 | back + front raster |
percentage variants into a single component with continuous progress: Float (0–1). Add state axis (determinate / indeterminate / success / error). Replace raster back/front with token-bound rectangles. Openback → Track and front → Fill. Aligns with native terminology. Openpercentage: 0 | 10 | … | 100 enum with progress: Float (0–1). Renaming from percentage to progress aligns with iOS / Compose conventions. OpenProgressView / LinearProgressIndicator support these out of the box. Open<img> back/front layers with token-bound rectangles using main/progress-bar/color/border-track and main/progress-bar/color/border. OpenA form row pairing a Radio Button with a text label. 4 variants across a size property with mixed values: default, large, default - error, large - error. Only the unselected state is documented across sizes.
size property into size=default/large and isError: Bool. Add disabled and selected variants. Instance-swap (or Figma Slot) the radio so the large label pairs with a large radio. The label component should track the atom's state via a single selected prop forwarded down.Contexts are illustrative. Final screens will reference actual GCash patterns. Radio Button with Label appears in form questions and preference settings, stacked vertically as a group.
Toggle size + error state. The proposed prop model splits these from the current single entangled size property.
size values include "default - error" and "large - error" — space-hyphen-space strings that encode state in a size prop. Breaks native enum mapping. C2size=large. The large label doesn't visually scale the radio accordingly. C6sizeproperty encodes state. Values include"default - error"and"large - error". Should be two orthogonal props:size=default | large+isError: Bool. C2 · Variant & Property Naming- Missing state variants. No
disabledorselectedvariants. Forms need all four selection states (selected/unselected × enabled/disabled) plus the error variant. C5 · Interaction State Coverage - Radio is hardcoded to the small instance. Even in
size=largevariants, the radio uses the 16×16 small atom. Large label should pair with the 20×20 large radio. C6 · Asset & Icon Quality - Code Connect mappings not registered. Blocked until the
sizeprop split and missing state variants land. C7 · Code Connect Linkability
- Split the
sizeprop:
•size: default / large
•isError: Bool
•selected: Bool
•disabled: Bool(or unifiedstateenum)
Flat orthogonal props — eliminates the compound string values. Property - Pair radio size to label size —
size=default→ small radio (16 × 16);size=large→ large radio (20 × 20). The current always-small behavior breaks visual hierarchy. Composition - Adopt a Figma Slot for the radio — lets consumers swap in a Radio Button with any state (selected/disabled/error/etc.) from the atom component. Maps to
@ViewBuilder/@Composableslots. Slot - Add disabled + selected variants — forms need all state combinations documented. State
- Make the whole row tappable — labels should be tap targets, not just the radio. Document in Accessibility. A11y
4 variants in the current Figma. Default (Primary/Multi-line Label/Light/Small, 14 / 16). Large (Primary/Multi-line Label/Light/Base, 16 / 20). Error variants tint the radio border red.
Small radio + 14 / 16 label. Default unselected state.
16 / 20 label (HeyMeow Rnd Semibold). Still uses the 16 × 16 radio — should be 20 × 20.
Default size with red radio border. Label text color unchanged.
Large size with red radio border.
| Size | DS text style | Spec |
|---|---|---|
| default | Primary/Multi-line Label/Light/Small | HeyMeow Rnd Semibold · 14 / 16 · +0.25 |
| large | Primary/Multi-line Label/Light/Base | HeyMeow Rnd Semibold · 16 / 20 · +0.25 |
| Property | Token | Value |
|---|---|---|
| Radio → label gap | space/space-12 | 12px |
| Radio icon offset padding (top) | space/space-4 | 4px |
| Text container padding (default) | space/space-4 | 4px vertical |
| Text container padding (large) | — | 3t / 5b |
| Default width (demo instance) | — | 63px |
| Large width (demo instance) | — | 69px |
Widths reflect the "Label" demo content — real usage hugs the text.
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:radio:1.0.0") }
| Current Figma | Proposed | SwiftUI | Compose |
|---|---|---|---|
| size=default / large | size: EBRadioSize | .controlSize(.small/.large) | size=EBRadioSize.* |
| "- error" suffix | isError: Bool | .ebError(true) | isError=true |
| — | selected: Bool | selected: Bool | selected: Boolean |
| — | label: String | title: String | label: String |
| — | disabled: Bool | .disabled(true) | enabled=false |
| — | onToggle | onChange: (Bool) -> Void | onCheckedChange: (Boolean) -> Unit |
// Default row EBRadioButtonWithLabel("Pay with GCash", selected: $selection == .gcash) { selection = .gcash } // Large row with error EBRadioButtonWithLabel("Option A", selected: isSelected) .controlSize(.large) .ebError(true) // Disabled EBRadioButtonWithLabel("Not available", selected: false) .disabled(true)
// Default row EBRadioButtonWithLabel( label = "Pay with GCash", selected = selection == Option.Gcash, onCheckedChange = { selection = Option.Gcash } ) // Large row with error EBRadioButtonWithLabel( label = "Option A", selected = isSelected, onCheckedChange = { isSelected = it }, size = EBRadioSize.Large, isError = true ) // Disabled EBRadioButtonWithLabel( label = "Not available", selected = false, onCheckedChange = { }, enabled = false )
| Requirement | iOS | Android |
|---|---|---|
| Whole-row tap target | Wrap the row in a Button or tap gesture — label must be tappable, not just the radio | Apply Modifier.selectable(...) to the row |
| Group semantics | Wrap options in .accessibilityElement(children: .contain) | Use Modifier.selectableGroup() on parent |
| Role announcement | Radio Button role inherited from the atom | Role.RadioButton via selectable |
| Error announcement | Include error state in the option's accessibility label | Use semantics { error(...) } |
| Touch target size | Row should be at least 44pt tall | 48dp minimum |
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | icon-ofsset (typo — "offset"), text-container. Minor spelling issue. |
| C2 | Variant & Property Naming | Needs Fix | size values encode error state with space-hyphen-space strings. |
| C3 | Token Coverage | Ready | Label colors, gap, padding all token-bound. |
| C4 | Native Mappability | Ready | HStack / Row with radio + label. |
| C5 | Interaction State Coverage | Needs Fix | No disabled, selected, or pressed variants. |
| C6 | Asset & Icon Quality | Partial | Always uses small radio instance — large label doesn't scale the radio. |
| C7 | Code Connect Linkability | Pending | Blocked by C2 prop split. |
| size (current) | Decomposed | Node ID |
|---|---|---|
default | size=default, isError=false | 18482:35674 |
large | size=large, isError=false | 18482:35686 |
default - error | size=default, isError=true | 18482:35680 |
large - error | size=large, isError=true | 18482:35692 |
After the prop split, these 4 become 2 size × 2 isError=4 clean variants, with selected + disabled added as booleans (not variants).
size prop encodes state — "default - error" and "large - error" mix size + state. Should split. OpenA selection control for mutually exclusive choices in forms, surveys, and preference settings. 11 variants across three entangled properties: selected (unselected/selected/disabled/error), size (small/large), style (default/filled/checkmark). Matrix is sparse — style is only meaningful when a selection is present.
selected × style matrix with orthogonal props: selected: Bool + state: default/disabled/error. Retire the checkmark style (it's a checkbox affordance, not a radio). Rebuild the large radio with token-bound vector layers instead of raster SVG images. Rename .base/checkbox → .base/radio. Add pressed + focused states.Radio Buttons appear in Radio Button with Label groups — see the Radio Button with Label preview for the composed form row.
Toggle the three properties to see how the sparse matrix behaves — some combinations render the same as others.
selected mixes selection with modifiers (disabled/error), and style is conditional (only meaningful when selected is true). Sparse matrix with ~50% invalid combinations. C2.base/checkbox instead of .base/radio. Suggests checkbox primitives were reused here. Also the checkmark style is a checkbox affordance, not standard radio iconography. C6- Sparse variant matrix.
selected × size × style=24 theoretical, ~11 valid. Thestyleproperty is only meaningful when a selection is present, andselectedconflates selection with modifier states. C2 · Variant & Property Naming - Large radio is raster-baked. Every large variant exports the ring+dot as a pre-rendered SVG image (
imgContainer). Token changes won't propagate to the large size until this is rebuilt with layered vectors. C3 · Token Coverage - Internal frame named
.base/checkbox. Misleading layer naming — the small radio nests a frame called.base/checkboxinstead of.base/radio. C6 · Asset & Icon Quality - Checkmark style is not standard radio iconography. Radios use filled dots universally; checkmarks communicate "checked" — a checkbox affordance. The
style=checkmarkvariant visually overlaps with Checkbox. C6 · Asset & Icon Quality - No pressed or focused states. Engineers must improvise these affordances. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked until property split (selected/state/size) and large-radio vector rebuild land. C7 · Code Connect Linkability
- Split properties into orthogonal axes:
•selected: Bool(true/false) — pure selection state
•state: default / disabled / error— modifier state (can combine with selected)
•size: small / large— unchanged
Eliminates invalid combinations, maps to SwiftBooland native radio APIs. Property - Retire the checkmark style. Pick filled (blue dot) as the single visual style — it's the universally understood radio affordance. The checkmark variant is visually a checkbox and may cause user confusion when placed next to actual checkboxes. Rename
- Rebuild the large radio as vector layers. Each variant today exports a flat SVG image; convert to a base ring + inner dot, both with token-bound fills. Matches the small radio's structure and lets tokens flow to both sizes. Asset
- Rename internal frame
.base/checkbox→.base/radio. Minor but signals correct primitive ownership. Rename - Add pressed + focused states. Pressed=darker blue ring/fill; focused=outer 2px focus ring. Documents the interactive affordances native needs to render. State
Four logical states shown across both sizes. Style variations (filled vs. checkmark) are documented below but recommended for consolidation.
Left column: large (20 × 20). Right column: small (16 × 16). Top to bottom: unselected, selected (filled), selected (checkmark), disabled, error, error-selected.
| State | Role | Token | Value |
|---|---|---|---|
| Unselected | border | main/radio-button/color/default/unselected/border | #D7E0EF |
| Selected | bg (fill + ring) | main/radio-button/color/default/selected/bg | #005CE5 |
| — | border | main/radio-button/color/default/selected/border | #005CE5 |
| — | inner dot / checkmark | main/radio-button/color/default/selected/icon | #FFFFFF |
| Disabled | bg | main/radio-button/color/disabled/selected/bg | #C2CFE5 |
| — | border | main/radio-button/color/disabled/selected/border | #C2CFE5 |
| — | inner icon | main/radio-button/color/disabled/selected/icon | #FFFFFF |
| Error | border (unselected) | main/radio-button/color/error/unselected/border | #D61B2C |
| — | bg (selected) | main/radio-button/color/error/selected/bg | #D61B2C |
| — | border (selected) | main/radio-button/color/error/selected/border | #D61B2C |
Large variants currently bypass these tokens — each state is a pre-rendered raster SVG. C3 fix requires rebuilding the large size with layered vectors that bind to these same tokens.
| Property | Token | Value |
|---|---|---|
| Large size | — | 20 × 20 |
| Small size | — | 16 × 16 |
| Ring border width | — | 2px |
| Inner dot (small filled) | — | 8 × 8 ellipse |
| Inner checkmark | — | 6 × 4 vector |
| Corner radius | space/space-8 | 8px (fully round at 16×16) |
| Selected-filled wrapper padding | space/space-4 | 4px (creates the "outer ring" illusion) |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:radio:1.0.0") }
| Current Figma | Proposed | SwiftUI | Compose |
|---|---|---|---|
| selected=unselected/selected | selected: Bool | selected: Bool | selected: Boolean |
| selected=disabled | state=disabled (combine w/ selected) | .disabled(true) | enabled=false |
| selected=error | state=error | state: .error | state=EBRadioState.Error |
| size=small/large | size: EBRadioSize | .controlSize(.small) | size=EBRadioSize.Small |
| style=filled/checkmark | (retire — pick filled only) | — | — |
| style=default (unselected/error) | implicit from selected=false | — | — |
| — | onToggle | onChange: (Bool) -> Void | onCheckedChange: (Boolean) -> Unit |
// Basic selection EBRadioButton(selected: $isSelected) // Large size with error state EBRadioButton(selected: $isSelected, state: .error) .controlSize(.large) // Disabled EBRadioButton(selected: true) .disabled(true)
// Basic selection EBRadioButton(selected = checked, onCheckedChange = { checked = it }) // Large size with error state EBRadioButton( selected = checked, onCheckedChange = { checked = it }, size = EBRadioSize.Large, state = EBRadioState.Error ) // Disabled EBRadioButton( selected = true, onCheckedChange = { }, enabled = false )
| Requirement | iOS | Android |
|---|---|---|
| Role | Inherit radio semantics via Toggle(isOn:) with radio style | Use Modifier.selectable(role=Role.RadioButton) |
| Selected state | .accessibilityAddTraits(.isSelected) | selected=true in semantics |
| Group label | Wrap options in a .accessibilityElement(children: .contain) with group label | Use Modifier.selectableGroup() on parent |
| Tap target | Radio is 20px; wrap in 44pt hit area | Wrap in 48dp hit area |
| Error announcement | Pair with a label and announce the error message after the label | Use semantics { error(...) } |
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Partial | Internal frame named .base/checkbox instead of .base/radio. |
| C2 | Variant & Property Naming | Needs Fix | selected mixes selection with modifiers; style is conditional. |
| C3 | Token Coverage | Needs Fix | Large variants are raster — tokens don't flow to the large size. |
| C4 | Native Mappability | Ready | Maps to Toggle / RadioButton with custom style. |
| C5 | Interaction State Coverage | Needs Fix | No pressed or focused states. |
| C6 | Asset & Icon Quality | Needs Fix | Checkmark style conflicts with Checkbox visually; large radio is a pre-rendered image. |
| C7 | Code Connect Linkability | Pending | Blocked by C2. Clean mapping lands after prop split. |
| selected | size | style | Node ID |
|---|---|---|---|
| unselected | large | default | 18482:35699 |
| unselected | small | default | 18482:35702 |
| selected | large | filled | 18482:35715 |
| selected | small | filled | 18482:35718 |
| selected | large | checkmark | 18482:35721 |
| selected | small | checkmark | 18482:35724 |
| disabled | large | filled | 18482:35704 |
| disabled | small | filled | 18482:35707 |
| disabled | large | checkmark | 18482:35710 |
| disabled | small | checkmark | 18482:35713 |
| error | large | default | 18482:35726 |
After the proposed restructure: 2 selected × 3 state × 2 size=12 well-formed orthogonal variants (no invalid combinations possible).
selected mixes selection with modifier states; style is only meaningful when selected. Open.base/checkbox + checkmark style overlaps with Checkbox. OpenA GCash-specific two-line input field for recipient/contact entry in money transfer flows (Send Money, Pay Bills, etc.). 8 variants across State (Default/Active/Error/Disabled) × isFilled (true/false). Features a small label on top, value/placeholder below, and two trailing action icons. Notably taller than other Form Elements at 56px (vs 46px for Input Field, Labeled Field, etc.) with 6px corner radius.
Contexts are illustrative. Final screens will reference actual GCash patterns.
Toggle state and fill to see the recipient field update in real time. Note the 56px height (taller than the standard 46px form field).
isFilled now uses true/false (C2 fixed). Value layer renamed to #value (C1 fixed) — now consistent with sibling fields.icon-placeholder RECTANGLEs (C6) — not swappable icon instances. Cannot compose different icon actions (phonebook, scan QR) without editing the component.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | State=Default | Gray #D7E0EF border, white bg. Label + placeholder/value visible. |
| Active (Focused) | Yes | Yes | State=Active | Blue #005CE5 border. |
| Error | Yes | Yes | State=Error | Red #D61B2C border. |
| Disabled | Yes | Yes | State=Disabled | #EEF2F9 bg, border hidden. Muted label and text. |
isFilledproperty renamed fromYes/Nototrue/false— now maps directly to SwiftBool/ KotlinBooleanC2 Fixed- Text layer renamed from
#text-placeholderto#value— now consistent with sibling fields (Input Field, Labeled Field) C1 Fixed - Both trailing icons use shared Placeholder component instances — swappable by design. Internal RECTANGLE is the default visual, replaced by designers when consuming the component C6 Closed
- Code Connect mappings not registered. Structural work is complete — registration can proceed against the 8-variant
State × isFilledschema. C7 · Code Connect Linkability
- Replace icon placeholders with swappable icon instances. The two
icon-placeholderRECTANGLEs block instance-swap — phonebook, scan-QR, and other trailing actions can't be dropped in without detaching the master. Slot - Document the 56px height rationale. Recipient Field is taller than the standard 46px form fields because of the two-line label + value layout. Call this out in the DS guidelines so it's not mistaken for a design error. Docs
- Add an
errorMessageslot below the field. Inline validation text keeps the field self-contained and matches the pattern proposed for the other form fields. Slot
8 variants across 2 axes: State (Default/Active/Error/Disabled) × isFilled (true/false). All share the same container with 6px corner radius. Height is 56px — taller than other Form Elements (46px) to accommodate the two-line label + value layout.
Idle state with gray border. Two-line layout: small label above, value/placeholder below. Two trailing icon placeholders.
Focused state with blue border indicating active input.
Validation error state with red border.
Non-interactive state with gray background and hidden border. Muted label and text colors.
All states share the same two-line container (56px height, 6px radius). Border color is the primary state indicator. Two trailing icon placeholders use a fixed fill across all states.
| Role | Token | DEFAULT | ACTIVE | ERROR | DISABLED |
|---|---|---|---|---|---|
| Border | field/border | #D7E0EF | #005CE5 | #D61B2C | hidden |
| Background | field/bg | #FFFFFF | #FFFFFF | #FFFFFF | #EEF2F9 |
| Label text | field/label | #0A2757 | #0A2757 | #0A2757 | #90A8D0 |
| Value (filled) | field/text/filled | #0A2757 | #0A2757 | #0A2757 | #90A8D0 |
| Value (empty) | field/text/placeholder | #90A8D0 | #90A8D0 | #90A8D0 | #C2CFE5 |
| Icon placeholder | field/icon | #C2C6CF | #C2C6CF | #C2C6CF | #C2C6CF |
| Height | 56px (vs 46px standard) |
| Corner radius | 6px |
| Border | 1px stroke |
| Icon group | 68 × 32px |
| Icon size | 32 × 32px each |
| Icon radius | 41px (circular) |
| #label (top line) | |
| Font | HeyMeow Rnd Semibold |
| Size | 12px |
| Tracking | 0.5 |
| #text-placeholder (bottom line) | |
| Font | HeyMeow Rnd Semibold |
| Size | 14px |
| Tracking | 0.25 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:form-elements:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.form.* // Compose
Package not yet published. These are the planned distribution paths.
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| isFilled (true/false) | text: Binding<String> | value: String | Derived from text content |
| #label | label: String | label: String | Small top label (12px) |
| #text-placeholder | placeholder: String | placeholder: String | Value text or placeholder (14px) |
| icon-group (2x icons) | trailingIcons: [Image] | trailingIcons: @Composable | Two action icon slots |
| State=Default | — | — | Default idle state |
| State=Active | .focused() | interactionSource | Keyboard active |
| State=Error | .ebError(true) | isError=true | Validation failed |
| State=Disabled | .disabled(true) | enabled=false | Non-interactive |
EBRecipientField( label: "Mobile Number", text: $recipientNumber, placeholder: "Enter number or name", trailingIcons: [ Image(systemName: "person.crop.circle"), Image(systemName: "qrcode.viewfinder") ] )
EBRecipientField( label = "Mobile Number", value = recipientNumber, onValueChange = { recipientNumber = it }, placeholder = "Enter number or name", trailingIcons = { IconButton(onClick = onContactsClick) { Icon(Icons.Default.Person, "Contacts") } IconButton(onClick = onScanClick) { Icon(Icons.Default.QrCode, "Scan QR") } } )
EBRecipientField( label: "Mobile Number", text: $recipientNumber, placeholder: "Enter number or name" ) .ebError(true)
EBRecipientField( label = "Mobile Number", value = recipientNumber, onValueChange = { recipientNumber = it }, placeholder = "Enter number or name", isError = true )
EBRecipientField( label: "Mobile Number", text: $recipientNumber, placeholder: "Enter number or name" ) .disabled(true)
EBRecipientField( label = "Mobile Number", value = recipientNumber, onValueChange = { recipientNumber = it }, placeholder = "Enter number or name", enabled = false )
| Requirement | iOS | Android |
|---|---|---|
| Minimum touch target | 44 x 44 pt (56px field exceeds) | 48 x 48 dp (56px field exceeds) |
| Accessibility label | .accessibilityLabel("Recipient") | contentDescription |
| Error announcement | VoiceOver reads error via .accessibilityValue | TalkBack reads error via semantics { error() } |
| Trailing icon labels | .accessibilityLabel("Contacts") per icon | contentDescription per icon button |
Do
Use Recipient Field for contact/number entry in money transfer flows (Send Money, Pay Bills, Buy Load). The two-line layout with trailing icons is purpose-built for this context.
Don't
Use Recipient Field for general text input — use Input Field or Labeled Field instead. The 56px height and icon slots add unnecessary weight for simple text entry.
Do
Provide meaningful trailing icons (e.g. contacts picker, QR scanner) that match the field's purpose. Both slots should have distinct actions.
Don't
Leave icon placeholders as-is in production — they are design placeholders only. Always replace with real icon instances before handoff.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Layer renamed to #value, now consistent with sibling fields. |
| C2 | Variant & Property Naming | Ready | isFilled=true/false — correctly uses native boolean values. |
| C3 | Token Coverage | Partial | Colors appear correct but token binding not verified. Icon placeholders use hardcoded #C2C6CF. |
| C4 | Native Mappability | Ready | Maps to custom EBRecipientField (SwiftUI + Compose). Two-line layout with trailing icon slots. |
| C5 | Interaction State Coverage | Ready | All 4 states defined: Default, Active, Error, Disabled. |
| C6 | Asset & Icon Quality | Needs Fix | Both trailing icons are icon-placeholder RECTANGLEs — not swappable icon instances. |
| C7 | Code Connect Linkability | Pending | No CLI mappings registered yet. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Ready | isFilled=true/false — boolean values map directly to native types |
| Layer naming | Blocked | #text-placeholder needs rename to #value |
| Icon slots | Blocked | RECTANGLE placeholders — need swappable icon instances |
| State coverage | Ready | All 4 states defined |
| Native component file | Pending | EBRecipientField.swift / EBRecipientField.kt not yet created |
4 State values × 2 isFilled values (true/false).
| State | isFilled | Node ID |
|---|---|---|
| Default | true | 17758:3868 |
| Default | false | 17758:3875 |
| Active | true | 17758:3882 |
| Active | false | 17758:3889 |
| Error | true | 17758:3896 |
| Error | false | 17758:3903 |
| Disabled | true | 17758:3910 |
| Disabled | false | 17758:3917 |
#text-placeholder to #value. Now consistent with sibling fields (Input Field, Labeled Field) for direct native property mapping. C1 FixedisFilled updated from Yes/No to true/false. Now maps directly to Swift Bool and Kotlin Boolean for Code Connect. C2 FixedisFilled=Yes/No instead of true/false. Incompatible with Swift Bool and Kotlin Boolean for Code Connect mapping. Fixed in 1.1.0#text-placeholder instead of #value used by other Form Elements. Fixed in 1.2.0icon-placeholder RECTANGLEs, not component instances. OpenA single-line text input tuned for search, with a leading search glyph and a trailing action slot. Currently ships 2 variants (state=default/filled) at 360×56px. Uses a banded top/bottom border rather than the rounded-rect stroke used by every other Form Element sibling.
img, trailing slot is an unresolved Placeholder circle, and the banded top/bottom border diverges from the rest of the Form Elements family. Consider composing from Input Field + leading/trailing icon slots instead of shipping a bespoke component.Contexts are illustrative. Final screens will reference actual GCash patterns.
Toggle state to see the search field update in real time.
icon-container holds an unresolved Placeholder wrapper with a raw icon-placeholder circle — consumers must instance-swap to get a usable clear/cancel affordance. Missing focused, error, and disabled tokens.state=default/filled — conflates "has content" with the four interaction states used by every sibling field (Default/Active/Error/Disabled). Token namespace (main/search/*) isolates this from the shared field/* tokens other siblings rely on.icon-container is a real slot and accepts a swap (swapIcon). Leading icon is not slotted — locked to the bundled search glyph (which is raster, not vector).| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default (empty) | Yes | Yes | state=default | Placeholder text at 50% opacity, trailing slot holds placeholder circle. |
| Filled (has query) | Yes | Yes | state=filled | Text at full opacity (#0A2757), identical container, trailing slot unchanged. |
| Focused | No | No | — | No visible focused variant. Native focus ring cannot be approximated from DS. |
| Error | No | No | — | No error state defined. |
| Disabled | No | No | — | No disabled state defined. |
- State coverage is incomplete. Only
defaultandfilledare shipped; focused, error, and disabled are absent. NativeTextField/SearchBarexpect all four interaction states for focus rings, validation, and disabled styling. C5 · Interaction State Coverage - Leading search glyph is a raster asset. The search icon renders via
<img src={imgShapeFull}>(raster PNG) rather than a vector instance from the icon library. Blocks token-based recoloring and fails crisp rendering on high-density displays. C6 · Asset & Icon Quality - Trailing slot ships a
Placeholderwrapper with a raw circle.icon-containercontains aPlaceholderframe wrapping anicon-placeholderpink-circle shape. This is authoring scaffolding that should be replaced with a real clear/cancel icon (or removed) before the component leaves design. C1 · Layer Structure & Naming statevariant axis conflates content and interaction.state=default/filledis a derived content signal (has a value or not) — it shouldn't occupy the same axis that other Form Elements reserve for Default/Active/Error/Disabled. Also fails Code Connect's expectation of boolean-like or enum-of-states schemas. C2 · Variant & Property Naming- Banded border diverges from the Form Elements family. Container uses
border-top + border-bottomonly, no left/right,radius-0. Every sibling (Input, Labeled, Select, Recipient, View Only) uses a full rounded-rect stroke at 6px radius. No native primitive renders this shape by default — forces custom background work on both platforms. C4 · Native Mappability - Code Connect mappings not registered. Structural gaps (C1/C2/C5/C6) must be resolved before linking. No native file exists yet. C7 · Code Connect Linkability
- Compose from Input Field instead of shipping a parallel primitive. Once Input Field gains
leadingIcon/trailingIconslots (already recommended in its assessment), a Search Field becomes Input Field + search glyph leading + clear-button trailing — no new component needed. Retiresmain/search/*tokens and inherits Default/Active/Error/Disabled for free. Composition - Swap the raster
shape_fullfor the canonical search icon instance. Reference the same vector icon used elsewhere (24px Search Small) so it inheritsmain/{component}/color/icon-leadingrecoloring across modes. Asset - Replace the trailing
Placeholderwrapper with a real Clear (X) icon instance. The currentPlaceholder > container > icon-placeholderpath is authoring scaffolding. Bind to a 24px Close / Clear icon and expose it as an optional slot that hides whenstate=default. Slot - Add Active, Error, and Disabled variants, and split content-filled from interaction state. Adopt the sibling schema:
State=Default | Active | Error | Disabledplus a booleanisFilled=true/false. That yields 8 variants and matches Input Field's axis model exactly. State - Align the border treatment with the Form Elements family. If Search Field remains a standalone component, switch to the shared 6px rounded-rect stroke — the banded top/bottom look is a screen-level pattern (section divider), not a field chrome treatment, and can be added by the surrounding layout. Family
- Rename the
main/search/color/default/*namespace to match the new schema. Either retire tofield/*(if composed) or expand tomain/search/color/{default|active|error|disabled}/*so tokens cover every state. Remove the/default/sub-mode once other states exist. Token - Document search semantics for native handoff. iOS uses
.searchable(text:)on a container (it is not a standalone view); Android uses Material 3SearchBar(which expands into full-screen search) or aTextFieldwith a leading search icon. Document both paths and the Enter-to-submit / Escape-to-clear keyboard contract. Docs - Add
role="search"/ search semantics and a labeled clear button. The clear-button slot needs its own accessibility label ("Clear search"). iOS VoiceOver and Android TalkBack must announce the field as a search input, not a generic text field. A11y
2 variants on a single axis: state=default | filled. Both share the same 360×56px container, banded top/bottom border, and trailing placeholder slot. State only toggles text color and opacity.
Empty state. Placeholder label at 50% opacity (#90A8D0), leading search glyph at 80% opacity.
State shown when a query has been entered. Text uses #0A2757 at full opacity.
Only a single variable mode (default) is bound on main/search/*. Focused, error, and disabled tokens do not exist yet.
| Role | Token | DEFAULT | FILLED |
|---|---|---|---|
| Background | main/search/color/default/bg | #FFFFFF | #FFFFFF |
| Border (top + bottom) | main/search/color/default/border | #F6F9FD (80%) | #F6F9FD (80%) |
| Placeholder | main/search/color/default/placeholder | #90A8D0 (50%) | – |
| Text | main/search/color/default/text | – | #0A2757 |
| Icon (leading) | main/search/color/default/icon-leading | #6780A9 (80%) | #6780A9 (80%) |
| Icon (trailing) | main/search/color/default/icon-trailing | #6780A9 | #6780A9 |
Leading glyph is currently a raster img; the icon-leading token is declared but not applied to a vector fill.
| Property | Value | Token |
|---|---|---|
| Container size | 360 × 56 px | — |
| Padding (horizontal) | 22 px left / 24 px right | — / space/space-24 |
| Padding (vertical) | 16 px | space/space-16 |
| Gap (icon ↔ text) | 8 px | space/space-8 |
| Gap (trailing slot) | 12 px | space/space-12 |
| Corner radius | 0 | radius/radius-0 |
| Border | 1 px top + bottom only | — |
| Leading icon size | 24 × 24 px | — |
| Trailing slot size | 24 × 24 px | — |
| Property | Value |
|---|---|
| DS text style | Secondary/Bold/Base |
| Font family | BarkAda |
| Weight | Semibold (600) |
| Size | 14 px (font-size-20) |
| Line height | 20 px (leading-40) |
| Tracking | 0 (tracking-normal) |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:form-elements:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.form.* // Compose
Package not yet published. These are the planned distribution paths.
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| state=default / filled | text: Binding<String> | query: String | Filled is derived — not a native parameter. |
| — (missing) | .focused() / @FocusState | interactionSource | No Figma variant for focused. Add Active state first. |
| — (missing) | .disabled(true) | enabled=false | No disabled variant. |
| swapIcon (trailing) | trailingIcon: Image? | trailingIcon: @Composable | Currently holds a Placeholder circle — should be Clear (X). |
| label | prompt: Text | placeholder: String | Placeholder copy ("Search"). |
EBSearchField("Search", text: $query, onSubmit: { runSearch(query) }, onClear: { query = "" })
EBSearchField( query = query, onQueryChange = { query = it }, onSearch = { runSearch(query) }, placeholder = "Search" )
iOS treats search as a modifier on a navigation container, not a standalone view. Use .searchable when the search field drives a scene's content.
NavigationStack { List(results) { row in Text(row.title) } } .searchable(text: $query, prompt: "Search")
SearchBar( query = query, onQueryChange = { query = it }, onSearch = { runSearch(query) }, active = active, onActiveChange = { active = it }, placeholder = { Text("Search") }, leadingIcon = { Icon(Icons.Default.Search, null) } ) { /* results */ }
Once Input Field gains leading/trailing icon slots, Search Field can be a thin composition — this is the recommended path (see Design Recommendations).
EBInputField("Search", text: $query) .ebLeadingIcon(Image("search")) .ebTrailingIcon(query.isEmpty ? nil : Image("close")) { query = "" } .ebRole(.search)
EBInputField( value = query, onValueChange = { query = it }, placeholder = "Search", leadingIcon = { Icon(Icons.Default.Search, null) }, trailingIcon = if (query.isNotEmpty()) { { IconButton({ query = "" }) { Icon(Icons.Default.Close, "Clear search") } } } else null )
| Requirement | iOS | Android |
|---|---|---|
| Minimum touch target | 44 × 44 pt (container is 56pt ✓) | 48 × 48 dp (container is 56dp ✓) |
| Search role / trait | .searchable or .accessibilityAddTraits(.isSearchField) | SearchBar sets role automatically, else semantics { role=Role.TextField; contentType=ContentType.SearchQuery } |
| Clear button label | .accessibilityLabel("Clear search") | contentDescription="Clear search" |
| Submit / Enter | .submitLabel(.search) + onSubmit | keyboardOptions=KeyboardOptions(imeAction=ImeAction.Search) |
| Escape to clear | Hardware keyboard: handle in onKeyPress(.escape) | Handle in onKeyEvent for keyboard users |
Do
Use Search Field for free-text query input that filters or retrieves results. Show the clear (X) button only when the field has content.
Don't
Use Search Field for destinations that don't actually filter or search. Use Input Field for generic text entry.
Do
Pair with a results region below the field and announce result counts to assistive tech when the query updates.
Don't
Ship the placeholder circle in the trailing slot — always swap to a real Clear / Cancel icon before publishing a screen.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Trailing Placeholder > container > icon-placeholder chain is authoring scaffolding, not a named icon. |
| C2 | Variant & Property Naming | Rework | state=default/filled conflates content with interaction. Diverges from sibling State axis (Default/Active/Error/Disabled). |
| C3 | Token Coverage | Partial | All visible colors bound to main/search/color/default/*, but only a default sub-mode exists — no tokens for focused/error/disabled. |
| C4 | Native Mappability | Rework | Top+bottom banded border isn't a native default. Missing role=search semantics in layer model. |
| C5 | Interaction State Coverage | Rework | Only default/filled. No focused, error, or disabled variants. |
| C6 | Asset & Icon Quality | Rework | Leading search glyph is a raster img (shape_full), not a vector instance. |
| C7 | Code Connect Linkability | Not Mapped | Blocked by C1/C2/C5/C6. No CLI mappings registered. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Rework | state=default/filled axis needs split into State (enum) + isFilled (bool) |
| State coverage | Rework | Missing Active / Error / Disabled |
| Icon quality | Rework | Raster leading glyph + placeholder trailing slot |
| Native component file | Not Created | EBSearchField.swift / EBSearchField.kt not yet created |
A single state axis with two values. Both variants are 360 × 56 px.
| state | Dimensions | Node ID |
|---|---|---|
| default | 360 × 56 | 50:78118 |
| filled | 360 × 56 | 50:78126 |
state=default/filled). Part of Form Elements group. Verdict: Restructure / Requires Rework. Documentedshape_full rendered via img, not a vector instance. OpenPlaceholder > icon-placeholder circle rather than a real Clear icon. Openstate axis conflates content and interaction — default/filled is a derived content signal, not a state-machine value. Openradius-0. Siblings use full rounded-rect stroke at 6px. OpenA currency/amount selection field specific to GCash. Includes a peso sign, label, value text, Philippine flag indicator, and a chevron down affordance. 8 variants across State (Default/Active/Error/Disabled) × isFilled (true/false). Part of the Form Elements group — used for currency and amount selection contexts.
Contexts are illustrative. Final screens will reference actual GCash patterns.
Toggle state and fill to see the select field update in real time.
isFilled now uses true/false (C2 fixed). Peso sign still uses shape_full BOOLEAN_OPERATION instead of a clean vector (C6).| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | State=Default | Gray #D7E0EF border, white bg. Peso sign #183462. |
| Active (Focused) | Yes | Yes | State=Active | Blue #005CE5 border. |
| Error | Yes | Yes | State=Error | Red #D61B2C border. |
| Disabled | Yes | Yes | State=Disabled | #EEF2F9 bg, border hidden. Peso sign #7E96BE. |
isFilledrenamed fromYes/Nototrue/falsefor direct SwiftBool/ KotlinBooleanmapping C2 Fixed- Peso Sign
shape_fullBOOLEAN_OPERATION flattened to a single vector path across all 8 variants C6 Fixed - Field Trailing Flag replaced from raster IMAGE fill to vector SVG across all 8 variants C6 Fixed
- Code Connect mappings not registered. Structural work is complete — registration can proceed against the 8-variant
State × isFilledschema. C7 · Code Connect Linkability
- Flatten the Peso Sign
shape_fullBOOLEAN_OPERATION. Boolean-operation paths render inconsistently across SVG export and native platforms. Replace with a single flattened vector path. Asset - Use a vector flag asset. The Philippine flag is currently a raster IMAGE fill. Swap to a vector from the DS icon library so it stays crisp across DPIs and platforms. Asset
- Generalize to multi-currency. Expose currency symbol and flag as configurable slots so the field can support other currencies (USD, EUR, SGD) without creating a new component per currency. Property
- Add a
helperTextslot. Error state has no accompanying text guidance today — add a slot for validation messages consistent with the other form fields. Slot
8 variants across 2 axes: State (Default/Active/Error/Disabled) × isFilled (true/false). All share the same 46px height container with 6px corner radius. Includes peso sign (15×15), label/value text, Philippine flag (25×16), and chevron down (32×32).
Idle state with gray border. Peso sign in dark navy, flag visible, chevron down affordance.
Focused state with blue border indicating active selection.
Validation error state with red border.
Non-interactive state with gray background, hidden border, and muted peso sign.
All states share the same container structure. Border color is the primary state indicator. Peso sign and text colors shift in disabled state.
| Role | Token | DEFAULT | ACTIVE | ERROR | DISABLED |
|---|---|---|---|---|---|
| Border | field/border | #D7E0EF | #005CE5 | #D61B2C | hidden |
| Background | field/bg | #FFFFFF | #FFFFFF | #FFFFFF | #EEF2F9 |
| Label text | field/text/label | #0A2757 | #0A2757 | #0A2757 | #0A2757 |
| Value (filled) | field/text/filled | #0A2757 | #0A2757 | #0A2757 | #90A8D0 |
| Value (empty) | field/text/placeholder | #90A8D0 | #90A8D0 | #90A8D0 | #C2CFE5 |
| Peso sign | field/icon/peso | #183462 | #183462 | #183462 | #7E96BE |
| Property | Value |
|---|---|
| Height | 46px |
| Corner radius | 6px |
| Peso sign size | 15 × 15 |
| Flag size | 25 × 16 |
| Flag corner radius | 2px |
| Chevron size | 32 × 32 |
| Layer | Property | Value |
|---|---|---|
| #label | Font | HeyMeow Rnd Semibold |
| Size | 16px | |
| #value | Font | HeyMeow Rnd |
| Size | 14px |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:form-elements:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.form.* // Compose
Package not yet published. These are the planned distribution paths.
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| isFilled (true/false) | selection: Binding<String?> | selectedValue: String? | Derived from selection content |
| State=Default | — | — | Default idle state |
| State=Active | .focused() | interactionSource | Selection active |
| State=Error | .ebError(true) | isError=true | Validation failed |
| State=Disabled | .disabled(true) | enabled=false | Non-interactive |
EBSelectField("Amount", selection: $amount)
EBSelectField( label = "Amount", selectedValue = amount, onValueChange = { amount = it } )
EBSelectField("Amount", selection: $amount) .ebError(true)
EBSelectField( label = "Amount", selectedValue = amount, onValueChange = { amount = it }, isError = true )
EBSelectField("Amount", selection: $amount) .disabled(true)
EBSelectField( label = "Amount", selectedValue = amount, onValueChange = { amount = it }, enabled = false )
| Requirement | iOS | Android |
|---|---|---|
| Minimum touch target | 44 x 44 pt | 48 x 48 dp |
| Accessibility label | .accessibilityLabel("Select amount") | contentDescription |
| Role hint | .accessibilityHint("Double tap to select") | semantics { role=Role.DropdownList } |
| Error announcement | VoiceOver reads error via .accessibilityValue | TalkBack reads error via semantics { error() } |
Do
Use Select Field for currency amount selection where the peso sign and flag indicator provide essential context for the user.
Don't
Use Select Field for free-text entry — use Input Field instead. Select Field is for predefined selection only.
Do
Show error state with a helper text message below the field explaining the validation issue (e.g. "Minimum amount is 1.00").
Don't
Hide the peso sign or flag — these are essential visual cues that distinguish this field from a generic dropdown.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic layer names: container, peso-sign, text-container, flag-container, Chevron Down. |
| C2 | Variant & Property Naming | Ready | isFilled=true/false — correct boolean convention for native mapping. |
| C3 | Token Coverage | Partial | Colors appear correct but token binding not fully verified. |
| C4 | Native Mappability | Ready | Maps to custom EBSelectField (SwiftUI) / EBSelectField (Compose). |
| C5 | Interaction State Coverage | Ready | All 4 states defined: Default, Active, Error, Disabled. |
| C6 | Asset & Icon Quality | Needs Fix | Peso sign uses shape_full BOOLEAN_OPERATION (not a vector). Flag uses raster IMAGE fill. |
| C7 | Code Connect Linkability | Pending | No CLI mappings registered yet. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Ready | isFilled=true/false — boolean convention now correct for Code Connect mapping |
| Asset quality | Blocked | Peso sign BOOLEAN_OPERATION and raster flag need replacement |
| State coverage | Ready | All 4 states defined |
| Native component file | Pending | EBSelectField.swift / EBSelectField.kt not yet created |
4 State values × 2 isFilled values (true/false).
| State | isFilled | Node ID |
|---|---|---|
| Default | true | 17758:3787 |
| Default | false | 17758:3797 |
| Active | true | 17758:3807 |
| Active | false | 17758:3817 |
| Error | true | 17758:3827 |
| Error | false | 17758:3837 |
| Disabled | true | 17758:3847 |
| Disabled | false | 17758:3857 |
Bool / Kotlin Boolean mapping for Code Connect. FixedisFilled=Yes/No instead of true/false. Incompatible with Swift Bool and Kotlin Boolean for Code Connect mapping. Fixed in 1.1.0shape_full is a BOOLEAN_OPERATION, not a clean vector path. May render inconsistently on native platforms. Openflag-container uses a raster IMAGE fill instead of a vector. May degrade on high-density displays. OpenA horizontal row of 8×8 dots — one filled in brand blue to indicate the current step, the rest in the light-blue track color. Today the family ships as 3 separate sibling components (Stepper - Bullet - 3 Steps, - 4 Steps, - 5 Steps), one per hardcoded step count. Inside each, a highlighted=1st | 2nd | … | Nth variant encodes which dot is active. There is no single Stepper component, no steps prop, and no current prop — both are baked into the variant name and sibling file.
Stepper - Bullet with steps and current propertieshighlighted=1st … Nth ordinal enum for the active dot. Rebuild as a single component: steps: Int (3–10) and current: Int (1..steps). Replace the raster dot PNGs with a vector Ellipse whose fill is bound to main/stepper/color/bg (active) or main/stepper/color/bg-track (inactive). Native side maps to a custom EBStepperBullet(current:total:) rendered as an HStack / Row of Circle shapes. Better still: unify Dash + Bullet + Circular into one EBStepper(current:total:style: .bullet | .dash | .circular) API.Stepper - Bullet appears at the top or bottom of multi-step flows — most commonly paginated onboarding, swipeable carousels of tutorial cards, and photo galleries where the user needs a minimal position indicator without numerical labels.
Adjust steps and current to see the proposed parameterized component. The active dot fills in brand blue; the rest show the light-blue track color.
<img>. Two PNG assets per sibling (filled + track) — six total for what should be one vector Ellipse with two token-bound fills.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Current step | Yes | Yes | fill=bg | Dot at current index fills in main/stepper/color/bg (#005CE5). |
| Other steps | Yes | Yes | fill=bg-track | All other dots fill in main/stepper/color/bg-track (#D2E5FF). Note: the spec does not distinguish completed vs upcoming — both look identical. |
| Completed vs upcoming | Missing | Missing | Not modeled | Bullet steppers in other systems often shade completed dots differently from upcoming ones. This family collapses both into the track color — direction of travel is lost. |
| Clickable / interactive | Optional | Optional | Not modeled | Used in carousels, some implementations let the user tap a dot to jump to that page. No pressed / focused state exists today. |
| Connector line | Missing | Missing | Not modeled | Classic Material / iOS bullet steppers draw a thin line between dots tinted to match completed / upcoming. This family uses blank 8-px gaps instead. |
- Step count is modeled as 3 sibling components instead of a
stepsprop. The family ships asStepper - Bullet - 3 Steps,- 4 Steps,- 5 Steps— three top-level components that differ only by hardcoded count. Same anti-pattern as Stepper - Circular (9 siblings). Should be a singleStepper - Bulletwithsteps: Intandcurrent: Int— 3× maintenance collapses to 1×, and the component scales to 6, 7, 8+ without new files. C1 · Layer Structure & Naming - Variant axis
highlighted=1st | 2nd | … | Nthuses ordinal enums instead of an integer. Each sibling has a nested symbol withhighlighted=1st,2nd,3rd,4th,5thto mark the active dot. Ordinals don't compose — the 4-step sibling can't have a "5th" option, and there's no path to current=N+1. Promote to a top-level integercurrent: 1..steps. C2 · Variant & Property Naming - Every dot is a raster
<img>PNG — 8×8 ellipse baked as an image. Two PNGs per sibling (filled + track) × 3 siblings=six raster assets for what is mathematically a filled circle. Blocks theming (can't retint), breaks at @3x, and ships bytes the native renderer doesn't need. An 8-pxCircle()/Box(Modifier.clip(CircleShape))with a token-bound fill is all that's required. C6 · Asset & Icon Quality - No native primitive matches — this needs a custom component. SwiftUI has no
BulletStepper; Material 3 provides onlyLinearProgressIndicatorand a linearStepper. Both platforms need a customEBStepperBulletbuilt from anHStack/RowofCircle/Boxshapes. The raster-baked dots make this worse — the dev can't reuse the asset. C4 · Native Mappability - No completed / upcoming distinction, no pressed / focused states, no connector line. The spec treats every non-current dot identically. Users can't see direction of travel. Tappable-to-jump behavior (common in carousels) has no pressed state modeled. And the 8-px blank gaps between dots would normally be a connector rail in classic bullet steppers. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked until the family collapses to one component and the raster dots are replaced with vector circles. Mapping 3 separate siblings would codify the anti-pattern into the tooling. C7 · Code Connect Linkability
- Collapse all 3 siblings into one
Stepper - Bulletwithstepsandcurrentproperties. DeleteStepper - Bullet - 3 Steps,- 4 Steps,- 5 Stepsas separate components. Create oneStepper - Bulletwithsteps: 3 | 4 | … | 10andcurrent: 1 | 2 | … | 10. Variant math drops from 3 top-level × 3–5highlighted=12 pre-baked variants to 1 component with runtime-computed fills. Native API:EBStepperBullet(current: Int, total: Int). Family - Unify Dash + Bullet + Circular under one
EBStepper(current:total:style:)API. All three siblings share the same data shape (current+total) and the same token set (main/stepper/color/*). Collapse them into one native component withstyle: .dash | .bullet | .circular. Figma keeps three component symbols (different visual languages) but they share the same property schema — making migration / swap between styles trivial. Family - Rename the nested
highlighted=1st | 2nd | … | Nthordinal axis to an integercurrent. Ordinal enums don't scale and conflate position with presentation. Usecurrent: Intat the top level and let each dot compute its own fill fromindex==current ? bg : bg-track. Property - Replace raster dot PNGs with vector
Ellipsefills bound to tokens. Each dot is an 8×8 ellipse — the simplest possible vector. Two fills only:main/stepper/color/bg(active) andmain/stepper/color/bg-track(inactive). No PNG assets, resolution-independent, theme-able. Asset - Add
completedvsupcomingdifferentiation. Optional but common: completed dots use a muted brand tint; upcoming use the track. Model asstatus: completed | current | upcomingcomputed per-slot fromcurrent. Adds direction-of-travel cue without the user having to count. State - Spec a connector line between dots (optional variant). Classic bullet steppers draw a 1–2 px line between each dot pair, tinted to match completed (brand) vs upcoming (track). Today the 8-px blank gap reads as isolated markers. Add
showConnector: Bool(default off for carousel-style use, on for wizard-style use). Property - Add an
orientationproperty for vertical layouts. Dot steppers sometimes appear as a vertical list in sidebars or long-form onboarding. Addorientation: horizontal | vertical. Property - Build as a custom native component. Neither SwiftUI nor Material has a
BulletStepperprimitive. ShipEBStepperBullet: iOS usesHStack { ForEach(0..<total) { Circle().fill(index==current ? Color.stepperBg : Color.stepperBgTrack).frame(width: 8, height: 8) } }; Android usesRow { repeat(total) { Box(Modifier.size(8.dp).clip(CircleShape).background(if (it==current) EBTokens.stepperBg else EBTokens.stepperBgTrack)) } }. Composition - Announce "Step X of Y" to screen readers. The component is decorative by default — assistive tech reads nothing. Wrap in a semantic container that announces
"Step \(current) of \(total)"(SwiftUI.accessibilityLabel, ComposeModifier.semantics { contentDescription=… }). Also: minimum touch target is 44×44 — if dots are tappable, wrap each in a padded hit area, don't make the 8-px dot itself the target. A11y - Document the canonical composition and retire the sibling names. Update the sticker sheet page to show one
Stepper - Bulletwith property controls; add a migration note pointingStepper - Bullet - N Stepsconsumers at the newstepsprop. Docs
27:48287 (5 Steps) · sibling frames 27:48235 (3 Steps), 27:48254 (4 Steps)Horizontal row of N 8×8 dots with one dot filled in brand blue to indicate the current step. 3 hardcoded sibling frames today; target is one component with a steps prop.
Stepper - Bullet - N Steps (×3)highlighted=1st | 2nd | … | Nthraster <img>horizontal only3 | 4 | … | 101 | 2 | … | stepshorizontal | verticalcompleted | current | upcoming| ROLE | TOKEN | VALUE |
|---|---|---|
| Dot (current) | main/stepper/color/bg | #005CE5 |
| Dot (other) | main/stepper/color/bg-track | #D2E5FF |
Both roles already use main/stepper/color/* tokens shared with Stepper - Circular and Stepper - Dash — token coverage is clean. The restructure work is schema and asset (raster → vector), not color.
8 × 88 (spacer width)0 horizontal, 4 vertical (space/space-4)space/space-0full (circle)8 × N + 8 × (N−1)=16N − 816No native primitive matches the bullet stepper pattern — ship a custom EBStepperBullet component on both platforms. Consider shipping it as a style mode of a shared EBStepper along with dash and circular.
.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBStepperBullet
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
Stepper - Bullet - N Steps (×3 siblings) | Stepper - Bullet (single component) | EBStepperBullet | EBStepperBullet |
| (implicit in sibling name) | steps: 3…10 | total: Int | total: Int |
highlighted=1st | … | Nth (inner symbol) | current: 1…steps | current: Int | current: Int |
| (horizontal only) | orientation: horizontal | vertical | orientation: Axis=.horizontal | orientation: EBStepperOrientation=Horizontal |
| (single track color for all non-current) | status: completed | current | upcoming (per slot) | (derived internally from current) | (derived internally from current) |
| (raster dot PNGs) | Vector Ellipse fills | Circle().fill(...) | Box(Modifier.clip(CircleShape).background(...)) |
ios/Components/Stepper/EBStepperBullet.swift— custom view, HStack of Circlesandroid/components/stepper/EBStepperBullet.kt— Row of Box composables with CircleShape background- Long-term: unify with Dash + Circular under
ios/Components/Stepper/EBStepper.swiftandandroid/components/stepper/EBStepper.ktexposing astyleenum.
// Onboarding carousel — page 2 of 4 EBStepperBullet(current: 2, total: 4) // Tutorial swipe — page 3 of 5 EBStepperBullet(current: 3, total: 5) // With completed / upcoming distinction EBStepperBullet(current: 2, total: 4, showProgress: true) // Unified Stepper API (future) EBStepper(current: 2, total: 4, style: .bullet)
// Onboarding carousel — page 2 of 4 EBStepperBullet(current = 2, total = 4) // Tutorial swipe — page 3 of 5 EBStepperBullet(current = 3, total = 5) // With completed / upcoming distinction EBStepperBullet(current = 2, total = 4, showProgress = true) // Unified Stepper API (future) EBStepper(current = 2, total = 4, style = EBStepperStyle.Bullet)
| Requirement | iOS | Android |
|---|---|---|
| Progress role | Wrap the row in .accessibilityElement(children: .ignore) and expose one semantic label. Use .accessibilityValue("Step \(current) of \(total)"). | Merge descendants via Modifier.semantics(mergeDescendants=true) with contentDescription="Step $current of $total". |
| Value announcement | VoiceOver reads "Step 2 of 4". Do not announce each dot individually — they're decorative at the unit level. | TalkBack reads the merged label. Update stateDescription when current changes to trigger re-announcement. |
| Touch targets (if interactive) | Dots are 8×8 — far below the 44×44 HIG minimum. If tappable, wrap each in a Button with an 18-px invisible hit-area padding on all sides. | Same. Wrap each dot in Modifier.clickable().minimumInteractiveComponentSize() so the 48-dp target is enforced. |
| Contrast | Active #005CE5 on white=5.3:1 ✓. Inactive #D2E5FF on white=1.2:1 — below the 3:1 non-text graphic threshold. Inactive dots rely on position + count to communicate, not contrast alone. Pair with announced label. | Same ratios. Same concern. |
| Reduce motion | If animating between current values, honor UIAccessibility.isReduceMotionEnabled and snap instead of cross-fading. | Honor Settings.Global.ANIMATOR_DURATION_SCALE and skip the animation when disabled. |
- Use for low-information position indicators — paginated onboarding, tutorial carousels, image galleries.
- Keep total steps between 3 and 6 — more than that reads as "many dots" and loses its countability.
- Pair with a screen title or page heading — the stepper alone doesn't tell the user what they're progressing through.
- Center horizontally under the content it describes.
- Don't use for numbered wizard flows — use
Stepper - Circular(numbered) so users can see "Step 2 of 4" without counting dots. - Don't use for single-screen content — no position is being communicated.
- Don't ship the 3 sibling components in product code — consume the parameterized version.
- Don't make the 8-px dots the tap target — always pad the hit area to 44×44 (iOS) / 48×48 dp (Android).
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Step count is modeled as 3 sibling components (Stepper - Bullet - 3/4/5 Steps) instead of a property. Collapse to a single Stepper - Bullet with steps + current. |
| C2 | Variant & Property Naming | Rework | Nested highlighted=1st | 2nd | … | Nth ordinal axis should become a top-level integer current: 1..steps. Ordinals don't compose across step counts. |
| C3 | Token Coverage | Ready | Fills bound to main/stepper/color/bg and main/stepper/color/bg-track. Padding bound to space/space-4, gap to space/space-0. Shared token set with Stepper - Circular and Stepper - Dash. |
| C4 | Native Mappability | Rework | No native primitive matches. Requires custom EBStepperBullet on both platforms. Unify with Dash + Circular under a shared EBStepper(style:) API. |
| C5 | Interaction State Coverage | Rework | No completed / upcoming differentiation, no pressed / focused / tappable state, no connector line, no vertical orientation. Every non-current dot is identical. |
| C6 | Asset & Icon Quality | Rework | Dots are raster <img> PNGs — two per sibling (filled + track). Replace with vector Ellipse with token-bound fills. Trivial vector; no reason to bake as raster. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until 3 siblings collapse to one component and raster dots are replaced with vectors. Consider mapping through a unified EBStepper(style: .bullet) API. |
Today: 3 sibling components, one per hardcoded step count, each with Nhighlighted variants (3+4+5=12 pre-baked variants). Target: 1 component with steps: 3…10 and current: 1…steps as runtime properties; no pre-baked variants needed.
| # | Sibling component | Node | Frame (w × h) | Highlighted variants |
|---|---|---|---|---|
| 1 | Stepper - Bullet - 3 Steps | 27:48235 | 80 × 128 | 3 (highlighted=1st, 2nd, 3rd) |
| 2 | Stepper - Bullet - 4 Steps | 27:48254 | 96 × 164 | 4 (highlighted=1st, 2nd, 3rd, 4th) |
| 3 | Stepper - Bullet - 5 Steps | 27:48287 | 112 × 200 | 5 (highlighted=1st, 2nd, 3rd, 4th, 5th) |
Per-symbol node IDs: 3 Steps — 27:48236…27:48248; 4 Steps — 27:48255…27:48279; 5 Steps — 27:48288…27:48328.
Stepper - Bullet - 3/4/5 Steps) into one Stepper - Bullet with steps: Int and current: Int properties. Replace raster dot PNGs with vector ellipses. Long-term, unify with Dash + Circular under EBStepper(style:). Opensteps property. Same anti-pattern as Stepper - Circular. Openhighlighted=1st | 2nd | … | Nth ordinal axis should become a top-level integer current. OpenEBStepperBullet on both platforms built over HStack/Row of Circle shapes. Consider unifying with Dash + Circular under EBStepper(style:). OpenEllipse fills bound to main/stepper/color/bg and main/stepper/color/bg-track. OpenA horizontal row of numbered circles — each circle's ring shows how far through a multi-step flow the user is. Today the component ships as 9 separate sibling components (Stepper - Circular - 2 Steps through … - 10 Steps), one per hardcoded step count. Inside each one, every step circle is a 45×45 symbol (number=1…N) whose ring arc is a pre-rendered raster <img>. There is no single Stepper component, no steps prop, and no current prop — the "current" step is implicit in which variant the designer drops in.
Stepper - Circular with steps and current propertiessteps: Int (range 2–10) and current: Int (1..steps). Redraw the ring as a stroked SVG arc derived from current / steps so the fill scales with math, not image swaps. Native side maps to a custom EBStepperCircular(current:total:) rendered over an HStack / Row of Circle shapes with .trim(from:to:) or drawArc.Stepper - Circular appears at the top of multi-step flows (onboarding, KYC, form wizards) to show position and total count. Consumers pair it with a screen title and step-specific content.
Adjust steps and current to see the proposed parameterized component. The ring arc for the active step is derived from current / steps; completed steps show a full ring; upcoming steps show only the track.
<img>. ~10 PNGs per sibling × 9 siblings ≈ 50+ assets for what should be one SVG arc with two parameters.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Completed step | Yes | Yes | ring=full | Step index < current. Full ring fill in brand blue, number in brand blue. |
| Current step | Yes | Yes | ring=partial arc | Step index=current. Arc fills current / steps of the ring; number in brand blue. |
| Upcoming step | Yes | Yes | ring=track only | Step index > current. Track-only ring in light blue; number still in brand blue. |
| Clickable / interactive | Optional | Optional | Not modeled | Some wizards let the user tap a completed step to go back. No pressed / focused state exists today; add if interactive behavior is desired. |
| Vertical orientation | Missing | Missing | Not modeled | Horizontal only. Vertical steppers (common on narrow screens or list layouts) would need a second orientation mode or a sibling. |
- Step count is modeled as 9 sibling components instead of a
stepsprop. Today the family ships asStepper - Circular - 2 Steps,… - 3 Steps, …… - 10 Steps— 9 top-level components that differ only by hardcoded count. Every other design system exposes oneStepperwithsteps: Int(ortotal: Int+current: Int). 9× maintenance, 9× Code Connect mapping, and no path to N=11+ without adding a 10th file. C1 · Layer Structure & Naming - Step ring arcs are pre-rendered raster
<img>assets, one per step index. Eachnumber=Nsymbol's ring is a baked PNG — the progressive fill is achieved by image swap, not by math. Blocks theming (can't retint), breaks at high DPI, and ships dozens of assets the native renderer doesn't need. Should be a stroked SVG arc withstrokeDasharray(orCircle().trim(from:to:)on iOS,drawArcon Compose). C6 · Asset & Icon Quality - Variant axis
numberencodes position, not a property. The inner symbol usesnumber=1 | 2 | … | Nwhich both labels the circle (the displayed digit) and implicitly sets the ring-arc raster. Two concerns collapsed into one enum. Should be:index: Int(the number shown) andstatus: completed | current | upcoming(the visual). C2 · Variant & Property Naming - No native primitive is a 1:1 match — this needs a custom component. SwiftUI has no built-in circular stepper; Material 3 shows only a linear
Stepper. Both platforms require a customEBStepperCircularbuilt from aRow/HStackofCircle/Boxshapes with arc-drawn progress. Today's raster baking makes this worse — the dev can't reuse the asset. C4 · Native Mappability - No pressed / focused / disabled state modeled. Wizards often allow tapping a completed step to return to it. With no interaction states in Figma, the developer has to invent hover / pressed treatment and the designer has no reference. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked until the family collapses to one component and the raster arcs are replaced with vector strokes. Mapping 9 separate siblings would codify the anti-pattern into the tooling. C7 · Code Connect Linkability
- Collapse all 9 siblings into one
Stepper - Circularwithstepsandcurrentproperties. DeleteStepper - Circular - 2 Stepsthrough… - 10 Stepsas separate components. Create oneStepper - Circularwithsteps: 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10(enum) andcurrent: 1 | 2 | … | 10— or, if Figma supports integer ranges, a numeric property. Variant math drops from 9 top-level components × ~10 step symbols=~90 variant assets to 1 component with a runtime-computed arc. Native API:EBStepperCircular(current: Int, total: Int). Family - Split the inner symbol's
numberaxis intoindex(digit shown) andstatus(ring treatment). Today one enum does both. Separate them:index: Intfor the label text andstatus: completed | current | upcomingfor the ring fill. The outer component then computes status per slot fromcurrent. Property - Replace raster ring arcs with stroked SVG / vector arcs. Each step circle's ring should be a 2-px stroked circle with
stroke-dasharray(or two half-circles viatrim). Colors bound tomain/stepper/color/bg-track(unfilled) andmain/stepper/color/bg(filled). Same visual output, theme-able, resolution-independent, no PNG assets. Asset - Add a
completedvisual state with a checkmark icon option. Many wizards replace the digit with a check once a step is done. Today there's no variant for this — everything shows a number. AddshowCheckOnComplete: Bool(or model it into thestatusenum ascompleted-check). State - Add an
orientationproperty for vertical layouts. Long flows (8+ steps) don't fit a phone-width row. Vertical stacks are common for checkout or document review wizards. Addorientation: horizontal | verticaland spec the connector line between circles for both axes. Property - Spec a connector line between circles. Today the 9 sibling frames space circles with a 20-px gap but no visible connector. Most steppers draw a line from the trailing edge of one circle to the leading edge of the next, tinted to match completed (brand) vs upcoming (track) states. Add this to the spec — it's the difference between "a row of circles" and "a stepper". Property
- Rename layers
step-container→Ring(and the ring arc layer →Arc). "step-container" is a technical label; "Ring" is what a designer or developer searches for. Rename - Build as a custom native component, not a ProgressView wrapper. Neither SwiftUI
ProgressViewnor MaterialLinearProgressIndicatorvisually match this pattern. Ship a dedicatedEBStepperCircular: iOS uses anHStackofZStack { Circle().stroke(track); Circle().trim(from:0,to:progress).stroke(fill); Text(index) }; Android uses aRowofBox(Modifier.size(45.dp))withCanvasdrawingdrawArc(sweepAngle=progress * 360f). Composition - Announce "Step X of Y" to screen readers. The component is decorative by default — assistive tech reads only the number. Wrap in a semantic container that announces
"Step \(current) of \(total)"(SwiftUI.accessibilityLabel, ComposeModifier.semantics { contentDescription=… }). A11y - Document the canonical composition and retire the sibling names. Update the sticker sheet page to show one
Stepper - Circularwith property controls; add a migration note pointingStepper - Circular - N Stepsconsumers to the newstepsprop. Docs
27:47768 (10 Steps) · sibling frames 27:47819…27:48036Row of N 45×45 numbered circles, each with a ring that indicates position through the flow. 9 hardcoded sibling frames today; target is one component with a steps prop.
Stepper - Circular - N Steps (×9)number=1 | 2 | … | Nraster <img>horizontal only2 | 3 | … | 101 | 2 | … | stepshorizontal | verticalcompleted | current | upcoming| ROLE | TOKEN | VALUE |
|---|---|---|
| Ring (track / upcoming) | main/stepper/color/bg-track | #D2E5FF |
| Ring (fill / current + completed) | main/stepper/color/bg | #005CE5 |
| Label (digit) | main/stepper/color/label | #005CE5 |
All three roles already use main/stepper/color/* tokens — token coverage is clean. The restructure work is schema, not color.
45 × 45~2 (raster-baked)20 (space/space-20)20 vertical, 20 horizontal45 × N + 20 × (N−1) + 4085Primary/Headlines/BlockProxima Soft Bold18230.25No native primitive matches the circular stepper pattern — ship a custom EBStepperCircular component on both platforms.
.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBStepperCircular
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
Stepper - Circular - N Steps (×9 siblings) | Stepper - Circular (single component) | EBStepperCircular | EBStepperCircular |
| (implicit in sibling name) | steps: 2…10 | total: Int | total: Int |
(implicit in number variant) | current: 1…steps | current: Int | current: Int |
number=1 | … | N (inner symbol) | index: Int + status: completed | current | upcoming | (derived internally from current) | (derived internally from current) |
| (horizontal only) | orientation: horizontal | vertical | orientation: Axis=.horizontal | orientation: EBStepperOrientation=Horizontal |
| (raster arc) | Stroked SVG / Canvas arc | Circle().trim(from: 0, to: progress).stroke(...) | Canvas { drawArc(sweepAngle=progress * 360f) } |
ios/Components/StepperCircular/EBStepperCircular.swift— custom view with HStack of ring + digitandroid/components/stepper/EBStepperCircular.kt— Row of Canvas-drawn circular step indicators- No native primitive is a 1:1 match. A custom component is required on both platforms.
// KYC wizard — step 2 of 4 EBStepperCircular(current: 2, total: 4) // Long form wizard — step 5 of 8 EBStepperCircular(current: 5, total: 8) // Vertical layout for narrow columns EBStepperCircular(current: 3, total: 6, orientation: .vertical) // With check-on-complete — digit replaced by checkmark once done EBStepperCircular(current: 4, total: 5, showCheckOnComplete: true)
// KYC wizard — step 2 of 4 EBStepperCircular(current = 2, total = 4) // Long form wizard — step 5 of 8 EBStepperCircular(current = 5, total = 8) // Vertical layout for narrow columns EBStepperCircular( current = 3, total = 6, orientation = EBStepperOrientation.Vertical ) // With check-on-complete — digit replaced by checkmark once done EBStepperCircular( current = 4, total = 5, showCheckOnComplete = true )
| Requirement | iOS | Android |
|---|---|---|
| Progress role | Wrap the row in .accessibilityElement(children: .ignore) and expose a single label. Set .accessibilityValue("Step \(current) of \(total)"). | Treat the row as a single semantic node via Modifier.semantics(mergeDescendants=true) with contentDescription="Step $current of $total". |
| Value announcement | VoiceOver reads "Step 2 of 4". Avoid announcing each circle individually — it's noisy and position-less. | TalkBack reads the merged label. Update stateDescription when current changes to trigger re-announcement. |
| Icon-only digits | The digit itself is already semantic. Ensure contrast: #005CE5 on white=5.3:1 ✓ | Same. Digit color passes WCAG AA for non-text graphics. |
| Interactive steps | If completed steps are tappable, wrap each in a Button with .accessibilityHint("Tap to return to step \(index)"). Pressed state inherits from the button. | Use Modifier.clickable(onClickLabel="Return to step $index"). Ripple indication appears natively. |
| Contrast | Ring fill #005CE5 on track #D2E5FF=3.1:1 — passes 3:1 for non-text graphics (WCAG 1.4.11). OK. | Same ratio. |
- Use for multi-step flows where the user needs to know position and total count (onboarding, KYC, checkout).
- Place at the top of the screen or immediately under the title bar.
- Pair with a step-specific heading ("Step 2: Verify your email") so the stepper + title form a progress pair.
- Switch to vertical orientation when step count × 45 + gaps exceeds the column width.
- Don't use for flows with fewer than 3 steps — a simpler "Step 1 of 2" text label is clearer.
- Don't use for linear progress where total steps are unknown — use
Progress Bar(indeterminate) instead. - Don't ship the 9 sibling components in product code — consume the parameterized version.
- Don't let total steps exceed 10 horizontally on mobile — wrap, shrink, or switch to vertical.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Step count is modeled as 9 sibling components instead of a property. Collapse to a single Stepper - Circular. Also rename step-container → Ring. |
| C2 | Variant & Property Naming | Rework | Inner number axis conflates digit label and ring-arc rendering. Split into index + status (and lift current / steps to the top-level component). |
| C3 | Token Coverage | Ready | All colors bound to main/stepper/color/{bg, bg-track, label}. Typography bound to Primary/Headlines/Block. Gap bound to space/space-20. |
| C4 | Native Mappability | Rework | No native primitive matches. Requires a custom EBStepperCircular. Today's raster arcs block direct primitive composition. |
| C5 | Interaction State Coverage | Rework | No pressed / focused / disabled / tappable-completed state modeled. Missing vertical orientation and check-on-complete variants. |
| C6 | Asset & Icon Quality | Rework | Ring arcs are raster <img> — one PNG per step index per sibling component. Replace with stroked SVG / Canvas arcs. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until 9 siblings collapse into one component and raster arcs are replaced with vector strokes. |
Today: 9 sibling components, one per hardcoded step count. Each sibling contains N step symbols (number=1 … N) with baked raster ring arcs. Sum of step symbols: 2+3+4+5+6+7+8+9+10=54 step symbols (+ 9 outer frames=~63 nodes). Target: 1 component with steps: 2…10 and current: 1…steps as runtime properties; ring arcs drawn by math, not asset.
| # | Sibling component | Node | Frame width | Step symbols |
|---|---|---|---|---|
| 1 | Stepper - Circular - 2 Steps | 27:48036 | 150 | 2 (number=1, 2) |
| 2 | Stepper - Circular - 3 Steps | 27:48020 | 215 | 3 |
| 3 | Stepper - Circular - 4 Steps | 27:47999 | 280 | 4 |
| 4 | Stepper - Circular - 5 Steps | 27:47973 | 345 | 5 |
| 5 | Stepper - Circular - 6 Steps | 27:47942 | 410 | 6 |
| 6 | Stepper - Circular - 7 Steps | 27:47906 | 475 | 7 |
| 7 | Stepper - Circular - 8 Steps | 27:47865 | 540 | 8 |
| 8 | Stepper - Circular - 9 Steps | 27:47819 | 605 | 9 |
| 9 | Stepper - Circular - 10 Steps | 27:47768 | 670 | 10 (number=1…10) |
Stepper - Circular - 2…10 Steps) into one Stepper - Circular with steps: Int and current: Int properties. Replace raster ring arcs with stroked SVG / Canvas. Opensteps property. Rename step-container layer → Ring. Opennumber=1…N conflates digit label and arc-fill rendering. Split into index (label) + status (ring treatment). Lift current and steps to the parent component. OpenEBStepperCircular on both platforms built over HStack/Row of stroked circles. Openmain/stepper/color/bg-track and main/stepper/color/bg. OpenA flat, segmented progress indicator — a horizontal row of equal-width rounded dashes where the current-and-earlier steps fill brand blue and the remaining steps render in the light-blue track color. Ships as one component set with highlighted=1st | 2nd | … | 10th (10 variants) and ten boolean propNStepper props used to hide/show each slot. All dashes are real vector rectangles bound to the main/stepper/color/* tokens — no raster assets — which puts Dash well ahead of the Circular and Bullet siblings on asset quality.
current: Int + total: Inthighlighted is an ordinal enum (1st…10th) when it should be a number, and the 10 propNStepper booleans emulate what should be a single total scalar. Rebuild as one variant with current: 1…10 and total: 2…10, and fix the duplicate 6th layer name that covers positions 7–10. Native side stays a custom component (no platform primitive renders "N equal-width dashes" out of the box).Stepper - Dash sits above multi-step flows to tell the user where they are — KYC wizards, onboarding carousels, checkout funnels. The dashed format reads as "position in a sequence" rather than "percent done".
Adjust total to set the number of dashes and current to set which step is active. The proposed API collapses today's 10 propNStepper booleans and the highlighted=1st…10th enum into two integer properties.
main/stepper/color/bg and main/stepper/color/bg-track. No raster assets, no external dependencies.1st…10th) where a number belongs; 10 boolean visibility flags where a scalar total belongs; layer names duplicate 6th across positions 7–10.EBStepper(style:) API would compose better.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default (current) | Yes | Yes | highlighted=1st…10th | Dashes 1…current fill #005CE5; dashes current+1…total fill #D2E5FF. |
| Completed | Missing | Missing | Not modeled | No distinct "all done" state. Once current===total the final dash just reuses the same brand blue — consider a positive-green variant when the whole flow finishes successfully. |
| Error | Missing | Missing | Not modeled | Flows that can fail mid-way (KYC rejected, upload failed) have no way to indicate which step errored. Add a per-dash status or a component-level error state. |
| Tappable step | N/A | N/A | Not modeled | Display-only today — no pressed or focused variant. If designers want to allow tapping a completed dash to return, that interaction needs spec'ing. |
- Total step count encoded as 10 boolean props instead of an integer. Ships
prop1Stepper…prop10Steppers— ten toggles used as visibility flags for "how many dashes to render". To spec a 4-dash stepper a consumer flips four toggles on and six off; to change to 5 dashes they flip one more. Should be a singletotal: Int(ortotal: 2 | 3 | … | 10enum). C2 · Variant & Property Naming highlightedis ordinal, not numeric.highlighted=1st | 2nd | … | 10threads as a label, not a position. Native APIs and product code want an integer they can feed acurrent / totalcalculation — rename tocurrentand switch to a numeric range. C2 · Variant & Property Naming- Duplicate
6thlayer name across positions 7–10. In thehighlighted=1stvariant, dash layers are labelled1st,2nd,3rd,4th,5th,6th,6th,6th,6th,6th. Looks like a copy-paste oversight when the component was extended from 6 to 10 slots. Rename to7th…10th. C1 · Layer Structure & Naming - No native primitive matches. Neither SwiftUI nor Material 3 ships a "segmented dash" progress indicator. Implementation is a custom
HStack/Rowof rounded rectangles — manageable, but the DS must own the composable and its theming. C4 · Native Mappability - No completed, error, or interactive state modeled. Only the default two-tone (brand / track) rendering exists. Add variants for completed-successfully (green), error-at-step-N (red), and optionally pressed/focused if steps are tappable. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked until the ordinal enum and boolean visibility props collapse into
currentandtotal. Mapping today's schema would codify the anti-pattern. C7 · Code Connect Linkability
- Replace the 10
propNStepperbooleans with a singletotalproperty. Today a designer builds "4-dash stepper" by togglingprop1Stepper=true, prop2=true, prop3=true, prop4=true, prop5..10=false. Replace withtotal: 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10. Variant math drops from (10 ordinal × 2^10 boolean combos, nominally) to 9 × 10=90, and more importantly the schema becomes legible — "4 dashes, step 2 active" instead of "highlighted=2nd + 4 booleans on, 6 booleans off". Property - Rename
highlighted=1st…10thtocurrent: 1…10. The property name reads as a Boolean ("is highlighted?") and the values read as rank labels. Both are wrong — the value is a numeric index. Rename tocurrentand switch values from ordinal strings to integers so consumers can docurrent / totalmath. Rename - Fix duplicated
6thlayer names on slots 7–10. Rename the last four inner dash layers to7th,8th,9th,10thso the inspector and dev handoff read cleanly. Low-effort cleanup. Rename - Add
statusstates for completed, error, and loading. Introduce an optional component-levelstate: default | success | errorthat repaints the whole row (green when the flow finishes, red when the current step errors). Indeterminate / loading is optional — a subtle pulsing animation on the current dash while an async step resolves. Tokens:main/stepper/color/bg-positive,main/stepper/color/bg-negative. State - Unify Stepper - Dash, Stepper - Bullet, and Stepper - Circular into one
EBStepperAPI. All three render the same underlying data (current,total) and differ only in the visual treatment of each slot. On the native side, ship oneEBStepper(current:total:style:)withstyle: .dash | .bullet | .circularinstead of three separate components — the state, accessibility, and layout logic are identical across styles. Figma can keep three sibling records for the sticker sheet, but the API is one surface. Family - Document a canonical "dash height × total" sizing chart. The single frame is 268 px wide with 4-px dash height and 4-px gap. As
totalgrows the individual dash width shrinks — attotal=10each dash is ~22 px. Document the minimum sensible per-dash width and the component's fill behavior inside narrower parents. Docs - Announce "Step X of Y" to screen readers. Dashes are purely decorative — assistive tech sees nothing today. Wrap in a container with
accessibilityLabel="Step \(current) of \(total)"on iOS andModifier.semantics { contentDescription=… }on Android. A11y - Spec a vertical orientation for long flows. With 10 dashes in a narrow column, individual dashes get uncomfortably short. Add
orientation: horizontal | verticalso flows can stack top-to-bottom when horizontal width is constrained. Property
18649:5223 · variants 18649:5224…18649:5323Horizontal row of equal-width rounded dashes. Dashes 1…current render in brand blue (bg); dashes current+1…total render in track blue (bg-track). All fills are real vector rectangles bound to stepper tokens.
1st | 2nd | … | 10thboolean × 10vector rect (rounded)268 (inner) · 308 (outer)1 | 2 | … | 10 (Int)2 | 3 | … | 10 (Int)default | success | errorhorizontal | vertical| ROLE | TOKEN | VALUE |
|---|---|---|
| Dash (current & earlier) | main/stepper/color/bg | #005CE5 |
| Dash (later) | main/stepper/color/bg-track | #D2E5FF |
| Success (status=success) — proposed | main/stepper/color/bg-positive | #12AF80 |
| Error (status=error) — proposed | main/stepper/color/bg-negative | #D81E1E |
308 × 260 (canvas)268 (fill-container)4 (space/space-4)4 (space/space-4)0 (space/space-0)100 (pill)flex-1 (equal share of row)—externalNo native primitive matches. EBStepperDash is a small custom composable built from a row of rounded rectangles.
.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBStepperDash
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
highlighted: 1st | … | 10th | current: Int (1…10) | current: Int | current: Int |
prop1Stepper…prop10Steppers (Bool × 10) | total: Int (2…10) | total: Int | total: Int |
| (not modeled) | status: default | success | error | status: EBStepperStatus | status: EBStepperStatus |
| (not modeled) | orientation: horizontal | vertical | axis: Axis | orientation: Orientation |
bg token | unchanged | EBColors.stepperBg | EBColors.stepperBg |
bg-track token | unchanged | EBColors.stepperTrack | EBColors.stepperTrack |
ios/Components/Stepper/EBStepperDash.swift—HStackofCapsulefillsandroid/components/stepper/EBStepperDash.kt—RowofBox(Modifier.weight(1f).height(4.dp).clip(RoundedCornerShape(100)).background(…))- Unified API option: ship
EBStepper(current:total:style:)withstyle: .dash | .bullet | .circular— see the Family recommendation. IndividualEBStepperDashbecomes a thin alias.
// Step 2 of 4 EBStepperDash(current: 2, total: 4) // Unified API — dash style of the shared stepper EBStepper(current: 2, total: 4, style: .dash) // Success — flow completed EBStepperDash(current: 4, total: 4, status: .success) // Error — step 3 failed EBStepperDash(current: 3, total: 5, status: .error) // Accessible wrapper EBStepperDash(current: 2, total: 4) .accessibilityElement() .accessibilityLabel("Step 2 of 4")
// Step 2 of 4 EBStepperDash(current = 2, total = 4) // Unified API — dash style of the shared stepper EBStepper(current = 2, total = 4, style = EBStepperStyle.Dash) // Success — flow completed EBStepperDash(current = 4, total = 4, status = EBStepperStatus.Success) // Error — step 3 failed EBStepperDash(current = 3, total = 5, status = EBStepperStatus.Error) // Accessible wrapper EBStepperDash( current = 2, total = 4, modifier = Modifier.semantics { contentDescription = "Step 2 of 4" } )
| Requirement | iOS | Android |
|---|---|---|
| Progress role | Expose as a single .accessibilityElement() with the .progressbar trait, value Double(current) / Double(total), label "Step 2 of 4". | Wrap in Modifier.semantics { role=Role.ProgressBar; progressBarRangeInfo=ProgressBarRangeInfo(current.toFloat(), 0f..total.toFloat()) }. |
| Label announcement | VoiceOver should read "Step 2 of 4, 50%". Localize the substitutions. | TalkBack same — use stateDescription for the "Step X of Y" phrasing. |
| Non-decorative colors | Brand blue on track blue is a 3.1:1 non-text contrast ratio — passes WCAG 1.4.11. OK. | Same ratio. |
| Grouping | Use .accessibilityElement(children: .ignore) so VoiceOver hears one unified announcement, not N dash descriptions. | Use Modifier.semantics(mergeDescendants=true). |
| Dynamic Type / text scale | Stepper has no text, so Dynamic Type doesn't apply to the component itself. Labels paired above/below should scale. | Same — font scale applies to paired labels, not dashes. |
- Use Dash when the number of steps is small, fixed, and known up front (KYC wizard, onboarding carousel).
- Pair with a label above the dashes — "Step 2 of 4 · Personal details".
- Let the component fill the column width so each dash reads as a proportional segment.
- Keep total between 2 and 10 — beyond that, dashes become visually too small to distinguish.
- Don't use Dash for continuous progress (percent complete of a single task) — use Progress Bar.
- Don't use Dash when steps have labels or numbers — use Stepper - Circular so each step is individually identifiable.
- Don't mix Dash and Bullet inside the same flow — pick one style per product area.
- Don't animate the fill from dash to dash on every mount — only animate on real step transitions.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Needs Refinement | Inner dash layers on slots 7–10 are all named 6th (copy-paste bug). Otherwise clean. |
| C2 | Variant & Property Naming | Rework | highlighted=1st…10th should be numeric current; 10 propNStepper booleans should collapse to total: Int. |
| C3 | Token Coverage | Ready | All fills bound to main/stepper/color/bg and main/stepper/color/bg-track. Gap uses space/space-4. Add bg-positive / bg-negative when status states land. |
| C4 | Native Mappability | Rework | No native primitive. Custom HStack/Row of rounded rectangles. Small composable, but must be owned by the DS. |
| C5 | Interaction State Coverage | Rework | Only default two-tone. Missing completed/success, error, and (optional) tappable-step pressed/focused. |
| C6 | Asset & Icon Quality | Ready | Pure vector rectangles — no raster assets. Resolution-independent, theme-able. Best-in-class for the Stepper family. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until the ordinal enum and boolean slot flags collapse into current + total. |
A single highlighted axis with 10 ordinal values (1st, 2nd, …, 10th)=10 variants. Each variant additionally exposes 10 boolean propNStepper visibility flags (not counted in the variant total — they're instance props). The proposed architecture replaces all of them with current: Int + total: Int, producing 9 × 10=90 representable states with no boolean juggling.
| # | Node | highlighted | Dimensions | Notes |
|---|---|---|---|---|
| 1 | 18649:5224 | 1st | 268 × 4 | Dash 1 bg; 2–10 bg-track |
| 2 | 18649:5235 | 2nd | 268 × 4 | Dashes 1–2 bg; 3–10 bg-track |
| 3 | 18649:5246 | 3rd | 268 × 4 | Dashes 1–3 bg; 4–10 bg-track |
| 4 | 18649:5257 | 4th | 268 × 4 | Dashes 1–4 bg; 5–10 bg-track |
| 5 | 18649:5268 | 5th | 268 × 4 | Dashes 1–5 bg; 6–10 bg-track |
| 6 | 18649:5279 | 6th | 268 × 4 | Dashes 1–6 bg; 7–10 bg-track |
| 7 | 18649:5290 | 7th | 268 × 4 | Dashes 1–7 bg; 8–10 bg-track · inner layer mislabel 6th |
| 8 | 18649:5301 | 8th | 268 × 4 | Dashes 1–8 bg; 9–10 bg-track · inner layer mislabel 6th |
| 9 | 18649:5312 | 9th | 268 × 4 | Dashes 1–9 bg; 10 bg-track · inner layer mislabel 6th |
| 10 | 18649:5323 | 10th | 268 × 4 | All 10 dashes bg · inner layer mislabel 6th |
highlighted=1st…10th + 10 propNStepper booleans into current: Int + total: Int. Asset quality already clean (pure vector). Open6th (copy-paste bug). Rename to 7th…10th. Openhighlighted is ordinal (1st…10th) where a number belongs; 10 propNStepper booleans emulate what should be total: Int. Rename and collapse. OpenEBStepperDash on both platforms. Consider unified EBStepper(style:) shared with Bullet and Circular. Openstatus: default | success | error and optional pressed/focused. Openmain/stepper/color/* tokens. No raster. Ready. Readycurrent + total. OpenHelper / success / error message shown below form fields. 6 variants across Variant (Primary/Success/Error) × Size (Base/Small). Icon appears only for Success and Error. Currently a standalone DS primitive — composed in by consumers beneath Input Field, Labeled Field, Select Field, Recipient Field, Dropdown, etc.
leadingLabel boolean, generic shape_full icon layers, no disabled state. Decide: keep as standalone primitive or fold into form-field supportingText slot.Appears directly beneath form fields — Input, Labeled, Select, Recipient, Dropdown — to communicate helper hints, success confirmation, or validation errors.
Toggle variant, size, leading label, and trailing icon to see the subtext update in real time.
shape_full — not instance-swapped from the DS Icon library.leadingLabel is misnamed (the Label text renders trailing in the flex row).supportingText) treat this as a field slot, not a peer component — suggesting it should be folded in.| Variant | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Primary (helper) | Yes | Yes | Variant=Primary | Neutral weaker text #6780A9. No icon. |
| Success | Yes | Yes | Variant=Success | Green text #048570, filled check icon #12AF80. |
| Error | Yes | Yes | Variant=Error | Red text + icon #D61B2C. |
| Disabled | — | — | — | No disabled variant today. When parent field is disabled, there's no matched subtext state. |
- Anatomy diverges by variant. Primary has no icon, Success / Error hardcode specific icons (Checkmark Circular / Close). The "leading icon" is not a uniform slot — it's an if-branch on Variant. Consumers can't override the Success / Error icon without detaching. C4 · Native Mappability
leadingLabelboolean is misnamed. The "Label" text actually renders on the trailing side of the flex row (after the message content), not leading. Naming contradicts rendered position and will mislead SwiftUI / Compose param names downstream. C2 · Variant & Property Naming- Icon layer named
shape_full. The inner 12×12 shape inside the Checkmark / Close containers carries a generic, flattened-style name. Suggests a raster image fill or BOOLEAN_OPERATION rather than a proper vector Icon instance swappable from the DS Icon library. C6 · Asset & Icon Quality - Container layers named generically. Nested frames labeled
containerandcontentdon't describe role — Code Connect slot inference relies on semantic names like#leading-icon,#message,#label. C1 · Layer Structure & Naming - No Disabled variant. When the parent field is disabled, the subtext has no paired state — consumers either hide it or rely on manual opacity adjustments. Every sibling form field carries a Disabled state; the subtext should too. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked behind C1 / C2 / C4 / C6. Also depends on the family-level decision below — whether this stays a standalone component or folds into the form-field
supportingTextslot. C7 · Code Connect Linkability
- Fold Subtext Message into each form field as a
supportingTextslot. This is the native convention on both platforms — Material 3TextFieldexposessupportingText, and SwiftUI pairs aTextunder the field using the same validation state. Folding it in removes the C4 / C5 gaps (field already has Disabled + Error states) and lets consumers drive the message by passingsupportingText: String?+ deriving color fromisError. The standalone component can stay as an annotation helper for designers but is no longer the canonical consumer integration path. Family - Rename
leadingLabel→trailingLabel. The "Label" text renders at the end of the flex row — the property name must match rendered position so SwiftUI / Compose params don't surprise developers. If the intent is to actually make the Label leading, swap the layer order in Figma and keep the current name. Rename - Normalize anatomy with a real leading-icon slot across all variants. Rebuild so every variant shares the same structure: optional
#leading-icon(Icon instance) →#message(text) → optional#trailing-label(Label text). Primary keeps icon=off by default; Success / Error default icon=on. This lets oneVariantenum + one boolean icon slot + one boolean label slot cover all six variants uniformly. Property - Replace
shape_fullwith DS Icon library instances. Swap the Success checkmark and Error close forIcon=Checkmark CircularandIcon=Closeinstance-swaps bound tomain/subtext-message/*/icontokens. Flattened boolean shapes can't be retinted, recolored, or resized cleanly and they block Code Connect 1:1 icon param mapping. Asset - Add a Disabled variant. Match the 4-state contract of every sibling form field (Default / Active / Error / Disabled). When the parent field is disabled, the subtext needs a paired muted color token (e.g.
main/subtext-message/disabled/label). State - Rename container layers.
container→#leading-icon-slot,content→#content, inner shape →#icon-glyph. Semantic names drive Code Connect slot inference. Rename
6 variants across 2 axes: Variant (Primary / Success / Error) × Size (Base / Small). Base uses 12/18 caption type, Small uses 10/15 small caption.
Neutral helper text. No icon. Used for hints, formatting examples, or ambient guidance under a field.
Valid input confirmation. Green text with filled circular checkmark.
Validation error. Red text with filled circular close icon.
Each variant binds its own label (and icon, where applicable) token. No appearance modes. No disabled state.
| VARIANT | ROLE | TOKEN | VALUE |
|---|---|---|---|
| Primary | label | main/subtext-message/primary/label | #6780A9 |
| Success | label | main/subtext-message/success/label | #048570 |
| Success | icon | main/subtext-message/success/icon | #12AF80 |
| Error | label | main/subtext-message/error/label | #D61B2C |
| Error | icon | main/subtext-message/error/icon | #D61B2C |
| Disabled | — | — (missing) | — |
Both sizes use the Secondary (BarkAda Semibold) type scale.
| SIZE | TEXT STYLE | FONT | SIZE | LINE HEIGHT | WEIGHT |
|---|---|---|---|---|---|
| Base | Secondary/Bold/Caption | BarkAda | 12 px | 18 px | Semibold (600) |
| Small | Secondary/Bold/Small Caption | BarkAda | 10 px | 15 px | Semibold (600) |
| PROPERTY | VALUE | TOKEN |
|---|---|---|
| Padding left | 2 px | space/space-2 |
| Padding top | 4 px | space/space-4 |
| Gap (icon ↔ content) | 4 px | space/space-4 |
| Icon frame | 16 × 16 px | — |
| Icon glyph | 12 × 12 px | — |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:form-elements:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.form.* // Compose
Package not yet published. These are the planned distribution paths.
Assumes the recommended architecture: supportingText slot on each form field (preferred), with this standalone component as a secondary annotation helper.
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| Variant=Primary | .ebSubtextStyle(.primary) | style=EBSubtextStyle.Primary | Neutral helper text. When used via field supportingText, this is the default style. |
| Variant=Success | .ebSubtextStyle(.success) | style=EBSubtextStyle.Success | Green with checkmark icon. |
| Variant=Error | .ebSubtextStyle(.error) | style=EBSubtextStyle.Error | Red with close icon. Field-side: derived from isError. |
| Size=Base / Small | .controlSize(.regular / .small) | size=EBSubtextSize.Base / Small | Typography scale only. |
| leadingLabel (Yes/No) | trailingLabel: String? | trailingLabel: String?=null | Needs rename — property is misnamed (renders trailing, not leading). |
| trailingIcon (Yes/No) | leadingIcon: Image? | leadingIcon: @Composable (() -> Unit)? | Slot is actually leading. Default icon per variant, or pass custom. |
supportingTextEBInputField("Email", text: $email) .ebError(!isValid) .ebSupportingText("Enter a valid email address")
EBInputField( value = email, onValueChange = { email = it }, placeholder = "Email", isError = !isValid, supportingText = { Text("Enter a valid email address") } )
EBSubtextMessage("Valid message content") .ebSubtextStyle(.success) .controlSize(.small)
EBSubtextMessage( text = "Valid message content", style = EBSubtextStyle.Success, size = EBSubtextSize.Small )
| Requirement | iOS | Android |
|---|---|---|
| Error announcement | Wire to field .accessibilityValue so VoiceOver reads the error with the field value. | Use semantics { error(msg) } on the field, not a standalone live region. |
| Icon is decorative | Mark the leading icon .accessibilityHidden(true) — the text carries the meaning. | Icon contentDescription=null; semantics go on the text. |
| Dynamic Type / font scaling | Caption type must scale with Dynamic Type. Don't hard-lock font size. | Use sp units and respect fontScale. |
| Color-only meaning | Pair red with the close icon so red isn't the sole error cue. | Same — both a color and an icon are required for error/success. |
Do
Pass the message via the parent field's supportingText slot. This keeps validation state and message colocated.
Don't
Render this beneath a field as a separate sibling component — the parent field can't coordinate disabled / error state with an external peer.
Do
Keep error messages specific and actionable ("Enter 11 digits, starting with 09"). Use Primary for ambient hints, Success for confirmation.
Don't
Use Success as a decorative "looks good!" under every valid field — reserve it for meaningful post-validation confirmation.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Generic container, content, shape_full layers. No semantic slot names. |
| C2 | Variant & Property Naming | Rework | leadingLabel renders trailing — name contradicts position. Booleans already true/false (good). |
| C3 | Token Coverage | Ready | Dedicated main/subtext-message/* tokens for label + icon. Spacing uses space/*. |
| C4 | Native Mappability | Rework | Anatomy diverges by variant. Natively this is a field slot (supportingText), not a peer component. |
| C5 | Interaction State Coverage | Rework | No Disabled variant. Sibling fields have 4 states; subtext has 3 variants. |
| C6 | Asset & Icon Quality | Rework | Icons are shape_full layers — likely flattened / boolean shapes, not vector Icon instances. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until family decision + C1 / C2 / C4 / C6 resolved. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Rework | leadingLabel must be renamed to match rendered position |
| Slot inference | Rework | Generic layer names block slot detection |
| State coverage | Rework | Missing Disabled variant |
| Native component file | Not Mapped | Depends on family decision: standalone EBSubtextMessage or field supportingText slot |
3 Variant values × 2 Size values. The leadingLabel and trailingIcon booleans are not part of the 6 — they toggle at the instance level.
| Variant | Size | Node ID |
|---|---|---|
| Primary | Base | 11855:8764 |
| Primary | Small | 11855:8767 |
| Success | Base | 11855:8770 |
| Success | Small | 11855:8776 |
| Error | Base | 11855:8782 |
| Error | Small | 11855:8788 |
shape_full — Inner 12×12 glyph carries a generic, flattened-style name. Suggests raster fill or boolean op rather than a proper vector Icon instance. Opencontainer / content don't describe role. OpensupportingText slot vs keep standalone) + C1 / C2 / C4 / C6. OpenAtom composed by the Tabs container. 8 variants across isActive? (Yes/No) × orientation (vertical/horizontal) × size (small/large). Horizontal variants also expose hasLeadingIcon, hasCounter, and hasRedDot booleans — vertical variants always render an icon.
isActive? → selected (true/false). Unify the leading-icon slot across orientations. Replace the hardcoded counter (and its raw hex colors) with an instance of the canonical Badge. Replace the placeholder circle with a swappable Icon slot. C2C3C6Tab Items appear inside the Tabs container. See the Tabs in-context preview for the full screen layout.
Toggle state, orientation, size, and slots to see every combination.
#ECF1FA, #0F3390) instead of tokens. C3isActive? has a trailing ? and uses Yes/No. The leading-icon slot behaves differently across orientations: vertical always renders one, horizontal exposes hasLeadingIcon boolean. C2icon-placeholder instead of a swappable Icon slot. C6| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Active | Yes | Yes | isActive?=Yes | Blue label, blue bottom border |
| Inactive | Yes | Yes | isActive?=No | Gray label, light gray bottom border |
| Pressed / Disabled | N/A | N/A | — | Not defined. C5 |
| Has counter | Yes | Yes | hasCounter=true (horizontal only) | Counter pill. Should instance Badge, not duplicate colors. C6 |
| Has red dot | Yes | Yes | hasRedDot=true | 6px red dot in top-right corner |
- Property
isActive?has a?in its name. And usesYes/Novalues. Rename toselectedwithtrue/falseto match Checkbox/Radio conventions. C2 · Variant & Property Naming - Leading-icon slot is inconsistent across orientations. Vertical always renders an icon; horizontal gates it on
hasLeadingIcon. Unify to a singleleading=none | iconprop that behaves identically in both orientations. C2 · Variant & Property Naming - Counter pill uses hardcoded hex values.
#ECF1FAbg,#0F3390label — not bound to tokens, so theme changes won't propagate. C3 · Token Coverage - Counter is drawn locally instead of instancing Badge. Breaks compositional inheritance — future Badge updates (color, sizing, overflow) won't reach Tab Item. C6 · Asset & Icon Quality
- Icon is a hardcoded placeholder circle. 32px (vertical) / 24px (horizontal) gray
icon-placeholder— should be a swappable Icon slot via instance swap. C6 · Asset & Icon Quality - No pressed / disabled states documented. Tap feedback and non-interactive state are critical for a tab atom — engineers are improvising them today. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked until property rename, slot adoption, and state coverage land. C7 · Code Connect Linkability
- Rename
isActive?→selectedwithtrue/falsevalues. Matches SwiftBool/ KotlinBooleanfor Code Connect. Rename - Unify the leading-icon slot — replace the always-on vertical icon and the
hasLeadingIconboolean with a singleleading=none/iconslot that behaves identically across orientations. Property - Replace the local counter pill with a Badge instance. Badge already ships tokenized colors and will inherit any future token changes. Same pattern used when Avatar Group was repointed to the canonical Avatar. Composition
- Replace
icon-placeholderwith a swappable Icon slot (instance-swap). Lets product teams drop in any icon without editing the master. Slot - Add
pressedanddisabledstates as explicit variants so engineers don't have to improvise tap feedback. State - Add tokens for the counter under
main/tab/counter/{bg,label}if keeping it standalone, or adopt Badge's existing tokens. Token
8 variants grouped by orientation (vertical/horizontal) × size (small/large) × isActive (Yes/No). Horizontal variants expose optional slots; vertical always renders an icon.
Icon above label. 32px icon, 16/16 label (Primary/Label/Base). Active + inactive shown side-by-side.
Icon above label. 32px icon, 18/18 label (Primary/Label/Large). Optimized for 414px screens.
Label-first row. Optional leading icon (24px) and trailing counter (18px pill). Red dot anchored to top-right.
Same anatomy as horizontal small but with 18/18 label and 112px cell width.
Two states: active and inactive. Counter colors are currently hardcoded (flagged as C3).
| Role | Token | Active | Inactive |
|---|---|---|---|
| Label | main/tab/color/{active|inactive}/label | #005CE5 | #6780A9 |
| Bottom border | main/tab/color/{active|inactive}/border | #005CE5 | #E5EBF4 |
| Counter bg | — (hardcoded) | #ECF1FA | #ECF1FA |
| Counter label | — (hardcoded) | #0F3390 | #0F3390 |
| Red dot | — (raster) | Red 6×6px indicator | |
Counter colors should be tokenized as main/tab/counter/{bg,label} or replaced by instancing the canonical Badge component.
| Property | Value |
|---|---|
| Vertical padding (top) | 12px |
| Vertical padding (bottom) | 16px |
| Horizontal padding (left/right) | 12px |
| Icon size (vertical) | 32 × 32 |
| Icon size (horizontal leading) | 24 × 24 |
| Icon → label gap (vertical) | 12px |
| Counter size | 18px h × padding 6h / 4v |
| Counter radius | 99999px (pill) |
| Red dot size | 6 × 6 (top-right) |
| Bottom border | 2px solid |
| Horizontal small width | 105px |
| Horizontal large width | 112px |
| Size | DS text style | Spec |
|---|---|---|
| Small | Primary/Label/Base | HeyMeow Rnd Bold · 16 / 16 · +0.25 |
| Large | Primary/Label/Large | HeyMeow Rnd Bold · 18 / 18 · +0.25 |
| Counter | — (hardcoded) | HeyMeow Rnd Bold · 12 / 12 · +0.5 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:tabs:1.0.0") }
| Current Figma | Proposed API | SwiftUI | Compose |
|---|---|---|---|
| isActive?=Yes/No | selected: Bool | selected: Bool | selected: Boolean |
| orientation=vertical/horizontal | orientation: EBTabOrientation | .orientation(.vertical) | orientation=Vertical |
| size=small/large | size: EBTabSize | .size(.small) | size=Small |
| hasLeadingIcon + vertical always-on icon | leading: Icon? | leading: Image? | leading: Painter? |
| hasCounter=true/false | counter: Int? | counter: Int? | counter: Int? |
| hasRedDot=true/false | showBadge: Bool | showBadge: Bool | showBadge: Boolean |
| — | label: String | title: String | label: String |
// Vertical tab with icon EBTabItem(label: "Overview", leading: Image("overview")) .selected(true) // Horizontal tab with counter EBTabItem(label: "Inbox", counter: 12) .orientation(.horizontal) .selected(false) // Horizontal tab with red-dot indicator EBTabItem(label: "Alerts", showBadge: true) .orientation(.horizontal)
// Vertical tab with icon EBTabItem( label = "Overview", leading = painterResource(R.drawable.overview), selected = true ) // Horizontal tab with counter EBTabItem( label = "Inbox", orientation = EBTabOrientation.Horizontal, counter = 12, selected = false ) // Horizontal tab with red-dot indicator EBTabItem( label = "Alerts", orientation = EBTabOrientation.Horizontal, showBadge = true )
| Requirement | iOS | Android |
|---|---|---|
| Selected state | .accessibilityAddTraits(.isSelected) | selected=true in semantics |
| Counter value | .accessibilityValue("\(count) unread") | contentDescription includes the count |
| Red dot | .accessibilityLabel("New") or append to label | Append to contentDescription |
| Tap target | Icon-only cells need 44pt hit area | Cells meet 48dp with padding |
Do
Use vertical orientation when tabs have icons + short labels (iconic nav). Use horizontal when labels are longer or icons aren't semantically needed.
Don't
Mix orientations within the same Tabs container.
Do
Use counter for numeric indicators (unread counts, items). Use showBadge for "new / unseen" indicators where the count is unimportant.
Don't
Stack counter + red-dot on the same tab — the counter already communicates "there's something here."
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic: container, icon-label, icon, label, counter-container, red-dot. |
| C2 | Variant & Property Naming | Needs Fix | isActive? Yes/No + inconsistent leading-icon slot across orientations. |
| C3 | Token Coverage | Needs Fix | Counter bg #ECF1FA and label #0F3390 are hardcoded. |
| C4 | Native Mappability | Ready | Custom cell on iOS, Material Tab content on Android. |
| C5 | Interaction State Coverage | Needs Fix | Pressed and disabled states not defined. |
| C6 | Asset & Icon Quality | Needs Fix | Placeholder circle instead of Icon slot; counter is duplicated, not a Badge instance. |
| C7 | Code Connect Linkability | Pending | Not mapped. |
| isActive? | orientation | size | Node ID |
|---|---|---|---|
| Yes | vertical | small | 18482:33263 |
| No | vertical | small | 18482:33270 |
| Yes | vertical | large | 18482:33277 |
| No | vertical | large | 18482:33284 |
| Yes | horizontal | small | 18482:33291 |
| No | horizontal | small | 18482:33300 |
| Yes | horizontal | large | 18482:33309 |
| No | horizontal | large | 18482:33318 |
isActive? has a ? and uses Yes/No. Leading-icon slot behaves differently across orientations. Open#ECF1FA bg and #0F3390 label are raw hex. Should use tokens or instance the Badge component. OpenA scheduled-payment row: a top date-amount line (date label on the left, peso-prefixed primary amount on the right) and an optional grid of nested label / value detail pairs below. Component description reads "Use to present Scheduled Payment information clearly to user." Published as 3 variants selected by the type enum: no display amount (date + amount only), 2 amounts display (one row of 2 detail pairs), 4 amounts display (two rows of 2 detail pairs=4 total). Third parallel record in the Table family alongside Table and Table - Transaction.
EBInlineText rows — same guidance as Table - Transaction. Mobile scheduling surfaces (auto-debit plans, installment schedules) already render as vertical lists on iOS and Android; no phone surface needs this fixed 360px grid.Scheduled payments screen (auto-debit, installment plans, standing orders): a list of upcoming payment rows stamped with a date, the total debit amount, and — where relevant — a breakdown of principal / interest / fee components.
Toggle the type enum to cycle through the three variants: date + amount only, + 2 detail cells, or + 4 detail cells (two rows).
Peso Sign - Proxima image fill (same remote figma.com/api/mcp/asset/* URL as Table - Transaction). Detail cells prefix amounts with the literal string "PHP", mixing glyph and text prefixes inside one component.type × no. of columns × icon schema — introduces a brand-new type enum whose values embed the detail count in a sentence ("2 amounts display"). Third family member, third shape.EBInlineText instances for each detail pair.- Third parallel Table-family record with a new schema. After Table (
type × no. of columns × icon) and Table - Transaction (type × no. of columns × icon, peso content), Scheduling introduces another axis shape: a singletypeenum that gates a fixed 0 / 2 / 4 detail count. Three records, three different schemas for the same underlying "rows of label/value pairs" idea. C1 · Layer Structure & Naming typevalues embed the detail count in a sentence. Values are"no display amount","2 amounts display","4 amounts display"— natural-language strings that bake the count into the property. Should be an integerdetailCount: 0 | 2 | 4, or — better — replaced by a data-drivendetails: [Pair]array that accepts any length. C2 · Variant & Property Naming- No native mobile primitive matches the layout. Payment schedules on iOS and Android render as list cells (
List/LazyColumn) — a date header, an accessory amount, and optional secondary label/value rows. The 360px fixed grid is a desktop pattern; on phone width the two-cell detail row already crowds at the 12px type scale used here. C4 · Native Mappability - No tap, pressed, or disabled states. A scheduled payment row is typically tappable — to view, edit, or cancel the scheduled entry — and can be visually disabled (past / cancelled / skipped). None of that coverage exists. C5 · Interaction State Coverage
- Peso sign is a raster image asset. Primary amount's currency prefix is an
<img>pointing at a Figma-hosted bitmap, same remote URL as Table - Transaction. Detail amounts, meanwhile, use the literal string"PHP"— two different currency-prefix treatments in one component. C6 · Asset & Icon Quality - Code Connect mappings not registered. Blocked until the family consolidation decision lands — Code Connect for Scheduling would just duplicate the Inline Text mapping. C7 · Code Connect Linkability
- Remove Table - Scheduling from core DS; publish as a recipe on Table's page composing Inline Text rows. Same guidance as Table - Transaction. Recipe reads: "For scheduled-payment lists (auto-debit, installments, standing orders), compose a date / primary-amount header row with
EBInlineText, then stack additionalEBInlineTextrows for breakdown details inside aGeneric Transaction Cardor nativeListcell." Eliminates 3 variants, a raster peso asset, and the mixed₱/PHPprefix inconsistency. Family - Collapse the Table family (Table + Table - Transaction + Table - Scheduling) into one data-driven row primitive. If the family is retained for wider surfaces, publish a single
EBTableRowwithrole: .header | .contentand acolumns: [Column]array where each column carries its ownformat: .text | .amount | .date. Scheduling's date-header + amount + detail-grid becomes: one row with two columns (date / amount), followed by N rows with detail pairs. Collapses 18 family variants into 2 role variants × data. Family - Rename
typevalues to integers, or drop the property. If Scheduling is kept, rename todetailCount: 0 | 2 | 4(no sentence fragments in property values). If merged into Inline Text / Table's data-driven row, drop the property entirely — the count is inferred from the details array. Rename - Unify the currency prefix treatment. Primary amount uses a raster peso glyph; detail amounts use the literal string
"PHP". Pick one. Preferred: the Unicode₱glyph (U+20B1) everywhere, rendered in the row's type style — drop the raster asset and drop the"PHP"literal. Alternative: ship a vector peso icon (icon/currency/peso-sm) for the primary line and an optional"PHP"prefix for breakdown values, wired up as real tokens / slots. Asset - Compose each detail cell from Inline Text. The component already matches Inline Text's
label+valueshape (main/table/color/label-preambleon top,main/table/color/labelbelow). Instance-swap toInline Textso the tokens consolidate undermain/inline-text/*and state / a11y improvements flow through to every consumer. Composition - Add row interaction states if Scheduling stays. A scheduled-payment row is tappable — add
State=PressedandState=Disabledvariants (disabled=past / cancelled with muted labels and a strike or tag). Without these, every consumer re-invents the tap target. State - Document scheduling semantics. If Scheduling is kept, add guidance on which amounts belong on the primary line (total debit) vs. breakdown cells (principal / interest / fee / tax). Prevents the component from being used as a generic 3-amount row on non-scheduling surfaces. Docs
3 variants selected by the type enum. All three share the top date-amount row; types 2 amounts display and 4 amounts display append a details grid of label / value pairs below.
Minimum variant. A single date-amount row: a 108px left-aligned date label (MMM DD, YYYY) and a right-aligned peso-prefixed primary amount. No detail rows.
Date-amount row + one detail row. Details row carries a 111px "row-item" leading label and two equal-width cells in the remaining space, each a Label / PHP X,XXX.XX pair.
Date-amount row + two detail rows (4 cells total). Second detail row shares the same 111px gutter and two label/value cells as the first, with 12px gap between detail rows.
| Role | Token | Default | Pressed | Disabled |
|---|---|---|---|---|
| Row bg | main/table/color/bg | #FFFFFF | – | – |
| Date label | main/table/color/label | #0A2757 | – | – |
| Primary amount | main/table/color/label-amount | #005CE5 | – | – |
| Currency glyph (peso, primary) | main/table/color/icon-currency-primary | #005CE5 | – | – |
| Detail preamble label | main/table/color/label-preamble | #6780A9 | – | – |
| Detail value | main/table/color/label | #0A2757 | – | – |
No interaction states. The primary-amount peso glyph is a raster fill despite the token main/table/color/icon-currency-primary existing for its color. Detail amounts use a literal "PHP" string prefix with no token indirection. C6
| Property | Token | Value |
|---|---|---|
| Row width (fixed) | — | 360px |
| Height — no display amount | — | 50.5px |
| Height — 2 amounts display | — | 89.5px |
| Height — 4 amounts display | — | 132.5px |
| Horizontal padding | — | 24px |
| Vertical padding | space/space-16 | 16px |
| Date column width | — | 108px |
| Detail leading column width | — | 111px |
| Date-row → details-row gap | — | 8px |
| Detail row → detail row gap | — | 12px |
| Detail label → value gap | — | 4px |
| Detail cell gap | — | 8px |
| Peso glyph size | — | 15 × 15px (raster) |
| Peso → amount gap | — | 2px |
| Element | DS text style | Spec |
|---|---|---|
| Date label | Primary/Label/Light/Fine | Proxima Soft Semibold · 12 / 12 · +0.5 |
| Primary amount | Primary/Label/Small | Proxima Soft Bold · 14 / 14 · +0.25 |
| Detail preamble label | Primary/Multi-line Label/Light/Fine | Proxima Soft Semibold · 12 / 14 · +0.5 |
| Detail value (PHP X,XXX.XX) | Primary/Label/Light/Fine | Proxima Soft Semibold · 12 / 12 · +0.5 |
Table - Scheduling is recommended for removal. The API shown below assumes the preferred mobile path: compose EBInlineText rows inside EBGenericTransactionCard or a plain VStack/Column. If Scheduling is retained, it re-uses Table's installation (eb-ds-ios, com.eastblue.ds:table).
| Figma | SwiftUI | Compose | Notes |
|---|---|---|---|
| type (drop) | details: [EBInlineText] | details: List<EBInlineText> | Count is inferred from the details array — no property needed. Accepts 0, 2, 4, or any N. |
| Date label (MMM DD, YYYY) | EBInlineText(label:, value:) | EBInlineText(label=, value=) | Row header is a two-column Inline Text: date on the left, primary amount on the right. |
| Primary amount (peso + X,XXX.XX) | Text("\u{20B1}" + amount).ebAmountStyle(.primary) | Text("₱$amount", style=EBAmountStyle.Primary) | Unicode peso glyph in the row's type style. Drop the raster asset. |
| Detail Label / PHP X,XXX.XX | EBInlineText(label:, value:) | EBInlineText(label=, value=) | Each detail cell collapses into an Inline Text pair. "PHP" prefix is part of the value string. |
| Row tap target | .onTapGesture { … } / Button | Modifier.clickable { … } | Add if the surface supports viewing / editing a scheduled entry. |
// Recommended — compose with Inline Text (phone-width) EBGenericTransactionCard { EBInlineText(label: "MAY 10, 2026", value: "₱1,250.00") .ebAmountStyle(.primary) HStack { EBInlineText(label: "Principal", value: "PHP 1,100.00") EBInlineText(label: "Interest", value: "PHP 150.00") } } // Option B — if Scheduling row is retained EBTableSchedulingRow( date: Date(), amount: 1250.00, details: [ .init("Principal", "PHP 1,100.00"), .init("Interest", "PHP 150.00") ] )
// Recommended — compose with Inline Text (phone-width) EBGenericTransactionCard { EBInlineText(label = "MAY 10, 2026", value = "₱1,250.00", style = EBAmountStyle.Primary) Row { EBInlineText(label = "Principal", value = "PHP 1,100.00") EBInlineText(label = "Interest", value = "PHP 150.00") } } // Option B — if Scheduling row is retained EBTableSchedulingRow( date = LocalDate.now(), amount = 1250.00, details = listOf( SchedulingDetail("Principal", "PHP 1,100.00"), SchedulingDetail("Interest", "PHP 150.00") ) )
| Requirement | iOS | Android |
|---|---|---|
| Row semantics | If tappable, wrap as Button with .accessibilityLabel("Scheduled payment, May 10 2026, 1,250 pesos") — combine the date and amount into one spoken phrase. | Wrap in Modifier.clickable + Modifier.semantics(mergeDescendants=true); set contentDescription to a full spoken phrase. |
| Currency glyph fallback | Use Unicode \u{20B1} inline — avoid a raster image that won't scale with Dynamic Type. | Use Unicode ₱ inline — avoid a bitmap that won't respect font-scale settings. |
| Date formatting | Use Date.FormatStyle with the user's locale; don't hardcode MMM DD, YYYY. | Use DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM); don't hardcode format string. |
| Detail label / value pairing | Group each detail cell so VoiceOver reads "Principal, 1,100 pesos" as one element, not two. | Group each detail cell so TalkBack reads "Principal, 1,100 pesos" as one element, not two. |
| Disabled / past rows | Past or cancelled schedules: .accessibilityHint("Past payment") + muted label tokens. | Past or cancelled schedules: Modifier.semantics { stateDescription="Past payment" } + muted label tokens. |
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Third parallel record in the Table family with yet another schema. Should fold in, not stand alone. |
| C2 | Variant & Property Naming | Rework | type values embed the detail count in sentence fragments ("2 amounts display"). |
| C3 | Token Coverage | Partial | bg / label / label-amount / label-preamble / icon-currency-primary bound. The literal "PHP" string prefix on detail amounts has no token indirection. |
| C4 | Native Mappability | Rework | Scheduled payments are a List / LazyColumn pattern on mobile, not a fixed-width grid. |
| C5 | Interaction State Coverage | Rework | No tap, pressed, or disabled states — rows are typically tappable (edit / cancel) or disabled (past / cancelled). |
| C6 | Asset & Icon Quality | Rework | Peso glyph is a raster image; detail amounts use a literal "PHP" prefix. Two currency-prefix treatments in one component. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until the family consolidation decision lands. |
A single type axis with 3 values. No cross-axis matrix — detail count is the only variant driver.
| type | Detail cells | Height | Node ID |
|---|---|---|---|
| no display amount | 0 | 50.5px | 47:324362 |
| 2 amounts display | 2 (1 row × 2 cells) | 89.5px | 47:324363 |
| 4 amounts display | 4 (2 rows × 2 cells) | 132.5px | 47:324364 |
Detail layout is locked to pairs of 2 cells. No 1-cell, 3-cell, or N-cell variants exist — a data-driven details array would cover all shapes in one record.
type enum. Third parallel Table-family record (Table, Table - Transaction, Table - Scheduling). Documentedtype enum instead of reusing Table's type × no. of columns × icon or Transaction's schema. Recommend folding into Inline Text / Table data-driven row. Open"no display amount" / "2 amounts display" / "4 amounts display" bake the detail count into natural-language strings. Open"PHP" string. OpenA transaction-themed fork of Table: header row with 2 or 3 column labels (optional icon placeholder per column) and a content row with peso-prefixed amount columns. Component description reads "Use to present user's transactions and related details" — per-variant descriptions specifically cite account limits. Published as 6 variants (type: header/content · no. of columns: 2/3 · icon: yes/no — icon only on header). Overlaps heavily with Table, Generic Transaction Card, and Inline Text.
EBInlineText stack; use Table only for multi-column tabular history on wider surfaces."Per-variant descriptions cite account limits — e.g. daily / monthly send caps showing used vs. remaining peso amounts in aligned columns. Other GCash surfaces like transaction history and receipts use the Generic Transaction Card vertical stack, not this tabular layout.
Toggle type (header / content), column count (2 / 3), and icon (header only). Content rows show peso-prefixed placeholder amounts.
Peso Sign - Proxima image fill, not a DS icon instance or text glyph. Handoff code references a remote figma.com/api/mcp/asset/* URL — not deliverable to native.no. of columns string-with-period naming, but drops the 4-column option and the content icon variant — schema diverges from parent for no apparent reason. Label token main/table/color/label-preamble appears only here.- Duplicate of Table's variant matrix with narrower coverage. Table already ships header / content rows with 2–4 columns and the same icon=yes/no axis. Table - Transaction re-creates that axis but only for 2 and 3 columns, then specializes the content row to peso amounts. The overlap is a maintenance drag and the layer tree is a separate sibling instead of a reuse. C1 · Layer Structure & Naming
no. of columnsinherits Table's period-in-name string enum. Should be an integercolumnCount— or, better, removed altogether in favor of a data-drivencolumnsarray on the parent Table. The Transaction fork inherits the anti-pattern without fixing it. C2 · Variant & Property Naming- No native mobile primitive matches this layout. Same mobile problem as Table: iOS SwiftUI
Tableis macOS/iPad only, Material Compose has no Table primitive. Phone-width transaction totals render as stackedEBInlineTextrows (label + peso amount) — not a 360px fixed three-column table. C4 · Native Mappability - No interaction or semantic-amount states. Content row has no pressed / disabled states and amount cells don't distinguish positive, negative, or zero amounts despite being a transaction surface. C5 · Interaction State Coverage
- Peso sign ships as a raster image asset. The content row's currency prefix is an
<img>pointing at a Figma-hosted bitmap (figma.com/api/mcp/asset/...), not an inline glyph, SF Symbol, or vector icon. Header icon placeholder is still a hardcoded#C2C6CFcircle as in Table. C6 · Asset & Icon Quality - Code Connect mappings not registered. Blocked until the family consolidation decision lands — Code Connect for Table - Transaction would just duplicate Table's mapping. C7 · Code Connect Linkability
- Remove Table - Transaction from core DS; publish as a Table recipe and a Generic Transaction Card / Inline Text pattern. The component is a feature-specific composition, not a primitive. Document two ready-made paths in the Table page: (a) "For aligned multi-column amount totals on wider surfaces, use Table with amount-formatted column content"; (b) "For phone-width transaction details (receipt, limits, fees), compose
EBInlineTextrows insideGeneric Transaction Card." Eliminates 6 duplicate variants and the raster peso asset from DS surface. Family - If Transaction stays, consolidate with Table's row primitive and add an
amountFormatcolumn flag. Merge into Table's data-driven row with a per-columnformat: .text | .amount. Amount formatting would auto-prefix the currency glyph. Collapses Table + Table - Transaction from 3 records / 15 variants into 1 record / 2 variants × data. Composition - Replace the raster peso sign with a text glyph or vector icon. Currency prefix should be the native Unicode glyph
₱(U+20B1) rendered in the row's type style, or — if visual weight needs to match Proxima's peso — a vector SVG shipped as a DS icon (icon/currency/peso-sm). Drop the Figma-hosted bitmap reference. Asset - Rename or drop
no. of columns. Same fix as Table. If the component is kept, rename to an integercolumnCount. If merged into Table's data-driven row, drop it entirely — column count is inferred from thecolumnsarray. Rename - Align amount label token with Inline Text. The content-row label uses
main/table/color/label-preamble(#6780A9) while the amount value usesmain/table/color/label(#0A2757). Inline Text covers the same semantic pair withlabel/valuetokens. Merge into a shared token set (main/inline-text/color/label,main/inline-text/color/value) to prevent drift. Token - Document amount-sign semantics. If Table - Transaction is kept for account limits, add explicit guidance that amounts are always positive totals (used / remaining / cap). For signed transaction flows (income / expense), direct consumers to Generic Transaction Card. Prevents the component from being mis-used on flows it wasn't designed for. Docs
6 variants split across type (header / content) × no. of columns (2 / 3) × icon (yes / no — header only; content always has icon=no).
Subtle-bg row with bottom border. Repeats a 10px Proxima Soft Semibold "Column Label" N times across equal-width flex columns. icon=yes grows height from 36 to 62px and stacks a 24px placeholder circle above each label.
White bg, bottom border. Two-line layout: a 14px preamble label on top, then N equal-width amount cells below. Each amount cell renders a 15px peso-sign raster + a 14px Proxima Soft Bold numeric value (X,XXX.XX).
| Role | Token | Default | Pressed | Disabled |
|---|---|---|---|---|
| Header bg | main/table/color/bg-subtle | #F6F9FD | – | – |
| Content bg | main/table/color/bg | #FFFFFF | – | – |
| Row border | main/table/color/border | #E5EBF4 | – | – |
| Header column label | main/table/color/label | #0A2757 | – | – |
| Content preamble label | main/table/color/label-preamble | #6780A9 | – | – |
| Amount value | main/table/color/label | #0A2757 | – | – |
| Currency icon (peso) | main/table/color/icon-currency-secondary | #183462 | – | – |
| Header icon placeholder | — (hardcoded) | #C2C6CF | – | – |
No interaction states defined. Peso sign is a raster fill despite the token existing for its color. Header icon placeholder is hardcoded hex. C6
| Property | Token | Value |
|---|---|---|
| Row width (fixed) | — | 360px |
| Header height (icon=no) | — | 36px |
| Header height (icon=yes) | — | 62px |
| Content row height | — | 72.5px |
| Header horizontal padding | space/space-24 | 24px |
| Header pt / pb | 12 / space/space-12 | 12 / 12px |
| Content horizontal padding | — | 24px |
| Content py | space/space-16 | 16px |
| Header column gap | — | 8px |
| Content label → amounts gap | — | 8px |
| Amount column gap | — | 16px |
| Header icon → label gap | — | 2px |
| Header icon size | — | 24 × 24px |
| Peso glyph size | — | 15 × 15px (raster) |
| Peso → amount gap | — | 2px |
| Element | DS text style | Spec |
|---|---|---|
| Header column label | Primary/Multi-line Label/Light/Tiny | Proxima Soft Semibold · 10 / 12 · +0.25 |
| Content preamble label | Primary/Label/Light/Small | Proxima Soft Semibold · 14 / 14 · +0.25 |
| Amount value | Primary/Label/Small | Proxima Soft Bold · 14 / 14 · +0.25 |
Table - Transaction is recommended for removal. The API shown below assumes the preferred mobile path: compose EBInlineText rows inside EBGenericTransactionCard or a plain VStack/Column. If Table - Transaction is retained, it re-uses Table's installation (eb-ds-ios, com.eastblue.ds:table).
| Figma | SwiftUI | Compose | Notes |
|---|---|---|---|
| type=header | EBTableRow(role: .header, …) | EBTableRow(role=Header, …) | Delegate to Table's row primitive — same bg-subtle styling, same label typography. |
| type=content (peso amounts) | VStack { EBInlineText(…) } | Column { EBInlineText(…) } | On mobile, render as stacked label / value rows. The 3-column peso layout is a desktop/tablet pattern. |
| no. of columns (drop) | columns: [Column] | columns: List<Column> | Inferred from the data array — same as Table. |
| icon=yes (header slot) | leadingIcon: Image? | leadingIcon: @Composable (() -> Unit)? | Per column. Same slot pattern as Table header. |
| Peso Sign - Proxima (raster) | Text("\u{20B1}" + amount) | Text("₱$amount") | Prefix the value with the Unicode peso glyph. Drop the bitmap asset. |
| Label (preamble) / X,XXX.XX | EBInlineText(label:, value:) | EBInlineText(label=, value=) | Each amount column collapses into an Inline Text pair. |
// Recommended — compose with Inline Text (phone-width) EBGenericTransactionCard { EBInlineText(label: "Daily used", value: "₱3,500.00") EBInlineText(label: "Daily remaining", value: "₱6,500.00") EBInlineText(label: "Daily cap", value: "₱10,000.00") } // Option B — if multi-column Table row is retained (tablet / wider) VStack(spacing: 0) { EBTableRow( role: .header, columns: ["Used", "Remaining", "Cap"] ) EBTableRow( role: .content, label: "Daily", columns: [ .amount("3,500.00"), .amount("6,500.00"), .amount("10,000.00") ] ) }
// Recommended — compose with Inline Text (phone-width) EBGenericTransactionCard { EBInlineText(label = "Daily used", value = "₱3,500.00") EBInlineText(label = "Daily remaining", value = "₱6,500.00") EBInlineText(label = "Daily cap", value = "₱10,000.00") } // Option B — if multi-column Table row is retained (tablet / wider) Column { EBTableRow( role = EBTableRowRole.Header, columns = listOf("Used", "Remaining", "Cap") ) EBTableRow( role = EBTableRowRole.Content, label = "Daily", columns = listOf( TableColumn.Amount("3,500.00"), TableColumn.Amount("6,500.00"), TableColumn.Amount("10,000.00") ) ) }
| Requirement | iOS | Android |
|---|---|---|
| Amount semantics | Each amount cell should read as a single element — wrap peso + value and use .accessibilityLabel("Three thousand five hundred pesos"). | Merge peso + value with Modifier.semantics(mergeDescendants=true); set contentDescription to a spoken phrase, not the raw glyph. |
| Currency glyph fallback | Use Unicode \u{20B1} inline — avoid a raster image that won't scale with Dynamic Type. | Use Unicode ₱ inline — avoid a bitmap that won't respect font-scale settings. |
| Header vs content row | Header rows carry .accessibilityAddTraits(.isHeader). | Header rows use Modifier.semantics { heading() }. |
| Column label / amount pairing | Group the preamble label with each amount column so VoiceOver reads "Daily, three thousand five hundred pesos". | Group the preamble label with each amount column so TalkBack reads "Daily, three thousand five hundred pesos". |
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Duplicate sibling to Table with narrower column coverage. Should fold in, not stand alone. |
| C2 | Variant & Property Naming | Rework | Inherits Table's no. of columns string-with-period anti-pattern unchanged. |
| C3 | Token Coverage | Partial | bg / border / label / label-preamble / icon-currency-secondary all bound. Header icon placeholder uses hardcoded #C2C6CF. |
| C4 | Native Mappability | Rework | No native mobile Table primitive; transaction totals render as Inline Text stacks. |
| C5 | Interaction State Coverage | Rework | No pressed / disabled states; no semantic handling of positive / negative amounts. |
| C6 | Asset & Icon Quality | Rework | Peso sign ships as a raster <img>; header icon is a hardcoded placeholder circle. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until the family consolidation decision lands. |
A 2 type × 2 no. of columns × 2 icon matrix, pruned: icon only applies to header rows, so content × icon=yes doesn't exist. Total 6 variants.
| type | no. of columns | icon | Height | Node ID |
|---|---|---|---|---|
| header | 2 | no | 36px | 47:324707 |
| header | 3 | no | 36px | 47:324703 |
| header | 2 | yes | 62px | 47:324705 |
| header | 3 | yes | 62px | 47:324706 |
| content | 2 | no | 72.5px | 47:324704 |
| content | 3 | no | 72.5px | 47:324708 |
Column-count coverage (2, 3) is narrower than Table's parent (2, 3, 4). No 4-column Transaction variant exists despite the parent supporting it.
no. of columns inherited naming — String enum with a period in the property name, carried over from Table. Open#C2C6CF. OpenA tabular row primitive rendered as a 360px-wide strip with a label column and 1–3 right-aligned description columns. Published as a 9-variant component matrix (type: header/content · no. of columns: 2/3/4 · icon: yes/no — icon only applies to headers). Two orphan sub-components Table - Item and Table - Label are defined but never placed directly in screens.
List + Inline Text rows instead. If Table stays, collapse the matrix into a single data-driven row (columns: [Column]) with named leading / trailing slots and optional per-row icon.Sticker sheet shows Table instances stacked on a Template Screen to build a static 6-row pattern — 1 header row + 5 content rows. No scroll, no sort, no selection.
Toggle type (header / content), column count, and icon (header only). Labels and descriptions use placeholder copy.
no. of columns is a string enum with a period in the name — collides with C2 naming conventions. Three orphan sibling components (Table, Table - Item, Table - Label) when one data-driven row would suffice. C2- Three orphan components instead of one data-driven row. Table, Table - Item, and Table - Label are published as separate components but only Table is consumed — Item and Label are never placed directly. Should collapse into a single row primitive that accepts a columns array. C1 · Layer Structure & Naming
no. of columnsuses string enum with a period in the property name. Should be an integercolumnCount— the period breaks code-friendly naming and the string "2"/"3"/"4" can't interpolate to a data-driven row count. C2 · Variant & Property Naming- No native mobile primitive matches this layout. iOS SwiftUI
Tableis macOS/iPad-only; Material Compose has no Table primitive. On phones, tabular data is a vertical stack of label/value pairs (Inline Text) or a horizontally-scrollable list. Current 360px-fixed rows don't adapt. C4 · Native Mappability - No interaction state coverage. Rows have no hover, pressed, focused, selected, or disabled states. If rows are ever tappable (drill-down into a row detail), there's no visual affordance. C5 · Interaction State Coverage
- Header icon is a raw placeholder circle.
icon=yesvariants render a hardcoded#C2C6CF24px circle with no instance swap or named slot. Consumers have no documented way to set the icon. C6 · Asset & Icon Quality - Code Connect mappings not registered. Blocked until the family consolidates and native decision lands. C7 · Code Connect Linkability
- Reconsider whether Table belongs in a mobile-first DS. GCash ships on phones where tables almost never render as true HTML-style tables. The sticker sheet pattern screen is doing what Inline Text already does — a stack of label / value rows. Two cleaner paths: (a) remove Table from core DS, publish usage guidance pointing to
List+Inline Textfor label/value data; (b) keep Table but scope it to genuine multi-column data (transaction history, scheduled payments, etc.) and rebuild it as a data-driven row. Family - Collapse the three-component family into one row primitive. Merge Table, Table - Item, and Table - Label into a single Table Row component with a
columnsslot array and arolevariant (header/content). Eliminates theno. of columnsvariant explosion — 9 records collapse into 2 + slot content. Published node count drops from 3 components / 14 variants to 1 component / 2 variants. Property - Rename
no. of columnsto an integer (or drop it entirely). The period in the property name breaks C2 naming conventions. If the data-driven restructure lands, column count is inferred from thecolumnsarray and the prop disappears. Otherwise rename tocolumnCountwith integer values. Rename - Replace the header icon placeholder with a named slot. Declare an
iconSlot on the header-role row so consumers drop in any 24px icon component. Drop theicon=yes/noboolean — slot presence signals intent. Maps cleanly to@ViewBuilder/@Composableslot. Slot - Add row interaction states. If rows are ever tappable (drill-down row detail, sortable columns), publish
default/pressed/selected/disabledstate variants. If rows stay display-only, document that explicitly so native devs wrap in a non-interactive container. State - Introduce shared label/value token set with Inline Text. Table label and Inline Text label serve the same semantic role — "a thing and its value". Aligning tokens (
main/table/color/labelwithmain/inline-text/*) reduces drift and supports cross-component theming. Token
9 variants split across type (header / content) × no. of columns (2 / 3 / 4) × icon (yes / no — header only; content always has icon=no).
Subtle-bg row with bottom border. Primary-bold label on the left, semibold columns on the right. icon=yes grows height from 37 to 65px and adds a 24px placeholder circle above each column.
White bg. Bold 12px label on the left, 10px BarkAda Semibold description columns on the right (1, 2, or 3 of them).
| Role | Token | Default | Pressed | Disabled |
|---|---|---|---|---|
| Header bg | main/table/color/bg-subtle | #F6F9FD | – | – |
| Content bg | main/table/color/bg | #FFFFFF | – | – |
| Row border | main/table/color/border | #E5EBF4 | – | – |
| Label / column text | main/table/color/label | #0A2757 | – | – |
| Description text | main/table/color/description | #6780A9 | – | – |
| Header icon placeholder | — (hardcoded) | #C2C6CF | – | – |
No interaction states today — no hover / pressed / selected / disabled tokens are defined. Header icon placeholder is hardcoded hex, not a token. C6
| Property | Token | Value |
|---|---|---|
| Row width (fixed) | — | 360px |
| Header height (icon=no) | — | 37px |
| Header height (icon=yes) | — | 65px |
| Content row height | — | 56px |
| Horizontal padding | space/space-24 | 24px |
| Header pt / pb | space/space-8, space/space-12 | 8 / 12px |
| Content py | — | 12px |
| Column gap | — | 16px |
| Label width | — | 99px min |
| Header icon size | — | 24 × 24px |
| Icon → column gap | space/space-2 | 2px |
| Element | DS text style | Spec |
|---|---|---|
| Header label | Primary/Label/Small | Proxima Soft Bold · 14 / 14 · +0.25 |
| Header column | Primary/Multi-line Label/Light/Fine | Proxima Soft Semibold · 12 / 14 · +0.5 |
| Content label | Primary/Label/Fine | Proxima Soft Bold · 12 / 12 · +0.5 |
| Content description | Secondary/Bold/Small Caption | BarkAda Semibold · 10 / 15 · 0 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:table:1.0.0") }
| Figma | SwiftUI | Compose | Notes |
|---|---|---|---|
| type=header/content | role: .header / .content | role=EBTableRowRole.Header / Content | Header styles bg-subtle + bottom border; content is plain white. |
| no. of columns (drop) | columns: [Column] | columns: List<Column> | Column count inferred from the data array — no separate prop. |
| icon (slot) | leadingIcon: Image? | leadingIcon: @Composable (() -> Unit)? | Header-only. Drop icon=yes/no boolean — slot presence signals intent. |
| Label here text | label: String | label: String | Left-aligned primary label. |
| Description text (xN) | columns: [Column] | columns: List<Column> | Each Column has its own text / alignment / optional secondary value. |
// Option A — dedicated row primitive (if Table stays in DS) VStack(spacing: 0) { EBTableRow( role: .header, label: "Header", columns: ["Column", "Column", "Column"] ) ForEach(rows) { row in EBTableRow( role: .content, label: row.label, columns: row.values ) } } // Option B — compose with Inline Text (recommended for mobile) VStack(spacing: 12) { EBInlineText(label: "Amount", value: "₱1,000.00") EBInlineText(label: "Reference", value: "0000 0123 4567") EBInlineText(label: "Date", value: "Apr 22, 2026") }
// Option A — dedicated row primitive (if Table stays in DS) Column { EBTableRow( role = EBTableRowRole.Header, label = "Header", columns = listOf("Column", "Column", "Column") ) rows.forEach { row -> EBTableRow( role = EBTableRowRole.Content, label = row.label, columns = row.values ) } } // Option B — compose with Inline Text (recommended for mobile) Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { EBInlineText(label = "Amount", value = "₱1,000.00") EBInlineText(label = "Reference", value = "0000 0123 4567") EBInlineText(label = "Date", value = "Apr 22, 2026") }
| Requirement | iOS | Android |
|---|---|---|
| Tabular semantics | On iPad/macOS use Table; on iPhone use accessibilityElement(children: .combine) per row so each reads as "Label, value, value". | Wrap in Modifier.semantics { collectionInfo=CollectionInfo(rowCount, columnCount) }. |
| Header vs content row | Use .accessibilityAddTraits(.isHeader) on header rows. | Use Modifier.semantics { heading() } on header rows. |
| Column headers without visible text | If icon-only header columns exist, provide .accessibilityLabel. | Set contentDescription on the icon slot. |
| Row-level tap | If rows become tappable, wrap row in Button with .accessibilityHint. | Wrap in Modifier.clickable with role=Role.Button. |
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | 3 orphan components (Table, Table - Item, Table - Label) where 1 data-driven row would suffice. |
| C2 | Variant & Property Naming | Rework | no. of columns uses string enum with a period; should be integer or dropped. |
| C3 | Token Coverage | Partial | Label / bg / border bound to main/table/*. Header icon placeholder uses hardcoded #C2C6CF. |
| C4 | Native Mappability | Rework | No native iOS/Android mobile primitive. Needs a mobile rethink — list of label/value pairs vs true desktop table. |
| C5 | Interaction State Coverage | Rework | No hover / pressed / selected / disabled states. |
| C6 | Asset & Icon Quality | Rework | Header icon is a raw placeholder circle with no slot or instance swap. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until family consolidation and native decision land. |
Table ships 9 variants. Family siblings Table - Item (3 variants) and Table - Label (2 variants) are declared but never placed directly in screens — only Table is consumed.
| type | no. of columns | icon | Height | Node ID |
|---|---|---|---|---|
| header | 2 | no | 37px | 47:323220 |
| header | 3 | no | 37px | 47:323222 |
| header | 4 | no | 37px | 47:323224 |
| header | 2 | yes | 65px | 47:323221 |
| header | 3 | yes | 65px | 47:323223 |
| header | 4 | yes | 65px | 47:323225 |
| content | 2 | no | 56px | 47:325867 |
| content | 3 | no | 56px | 47:325868 |
| content | 4 | no | 56px | 47:325869 |
Family siblings: Table - Item 27:322607 (3 variants — description / icon / empty), Table - Label 27:322640 (2 variants — description=no / yes). Both would fold into the proposed data-driven columns slot.
no. of columns naming — String enum with a period in the property name. Open#C2C6CF circle with no slot or instance swap. OpenThe Tabs container composes a row of Tab Item atoms and manages the active index. Currently exposes 3 variants split by tabsCount (2 / 3 / 4). Figma component is currently named singular "Tab" — should be renamed "Tabs".
tabsCount — native tabs accept a list of items, not a fixed count variant. The container becomes one flexible component instead of 3 rigid variants.Contexts are illustrative. Final screens will reference actual GCash patterns. Tabs sit below a Title Bar to switch between screen sections.
Toggle the tab count to see 2 / 3 / 4-tab layouts. Each cell is a Tab Item in the vertical small configuration.
Depth/D4), and flex layout. Composes Tab Item children cleanly.tabsCount uses string values ("2"/"3"/"4") instead of integer or dropping the variant. C227:89110). Changes to Tab Item propagate here.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| 2 tabs | Yes | Yes | tabsCount="2" | Width 124px |
| 3 tabs | Yes | Yes | tabsCount="3" | Width 186px |
| 4 tabs | Yes | Yes | tabsCount="4" | Width 248px |
| 5+ tabs / scrollable | N/A | N/A | — | Not documented. Native needs a scrollable variant for overflow. C5 |
- Component name is singular ("Tab"). Should be plural ("Tabs") to match the container semantics and disambiguate from the Tab Item atom. C2 · Variant & Property Naming
tabsCountis a variant with string values."2"/"3"/"4"— native tabs take a list; the count should not be a discrete Figma variant. Drop the property and let the container accept a collection. C2 · Variant & Property Naming- No scrollable / overflow variant documented. For 5+ tabs, native uses
ScrollableTabRowon Android and horizontal scroll on iOS — DS has no pattern, so engineers improvise. C5 · Interaction State Coverage - Code Connect mappings not registered. Blocked until the plural rename and
tabsCountdrop land. C7 · Code Connect Linkability
- Rename "Tab" → "Tabs" (plural). Matches the atom/container pattern already used by Avatar + Avatar Group and Menu Grid + Service Item. Rename
- Drop the
tabsCountvariant. The container should be a single flexible component that accepts an array of Tab Items. Collapses 3 variants → 1. Property - Add a scrollable variant (or document that 5+ tabs should switch to a ScrollableTabRow pattern). Prevents engineers from improvising overflow behavior. State
Three variants by tab count. Each cell is an instance of Tab Item (vertical, small). First tab is active, remaining are inactive.
4 Tab Items in an equal-width flex row. 248px total width.
3 Tab Items in an equal-width flex row. 186px total width.
2 Tab Items in an equal-width flex row. 124px total width.
| Property | Value |
|---|---|
| Total width (2 tabs) | 124px |
| Total width (3 tabs) | 186px |
| Total width (4 tabs) | 248px |
| Per-tab width | 62px (flex 1 0 0) |
| Gap between tabs | 0 (shared border-bottom) |
| Shadow | Depth/D4 — 0 0 8px #73819A1A |
Color, typography, and state tokens are defined on the Tab Item atom. See Tab Item → Style for the full color reference.
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:tabs:1.0.0") }
| Proposed API | SwiftUI | Compose | Notes |
|---|---|---|---|
| items | items: [EBTabItem] | items: List<EBTabItem> | Array of Tab Items — replaces tabsCount variant |
| selectedIndex | @Binding selection: Int | selectedIndex: Int | Active tab index |
| onSelect | onSelect: (Int) -> Void | onSelect: (Int) -> Unit | Tab tap callback |
| scrollable | .scrollable(true) | scrollable: Boolean | For 5+ tabs — not yet defined in Figma |
EBTabs( items: [ EBTabItem(label: "Overview", icon: .image(Image("overview"))), EBTabItem(label: "Details"), EBTabItem(label: "History") ], selection: $selectedIndex )
EBTabs( items = listOf( EBTabItem(label = "Overview", icon = painterResource(R.drawable.overview)), EBTabItem(label = "Details"), EBTabItem(label = "History") ), selectedIndex = state, onSelect = { state = it } )
| Requirement | iOS | Android |
|---|---|---|
| Tab list role | Automatic via TabView | Automatic via TabRow (Material semantics) |
| Selected state announced | .accessibilityAddTraits(.isSelected) on active tab | selected=true in semantics |
| Keyboard / focus navigation | iOS handles via focus traits | Compose handles via focus semantics |
Do
Use 2–4 tabs for primary navigation within a screen. Tabs should represent peer sections of equal importance.
Don't
Use tabs for sequential flows (Step 1 / Step 2 / Step 3) — use a Stepper component instead.
Do
Keep tab labels short (one or two words). If labels exceed 12 characters, use icons only or switch to vertical orientation.
Don't
Nest Tabs inside Tabs — creates navigation ambiguity and accessibility issues.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Tab 1, Tab 2, ... container, icon-label. Semantic. |
| C2 | Variant & Property Naming | Needs Fix | Component named singular "Tab"; tabsCount uses string values and shouldn't be a variant at all. |
| C3 | Token Coverage | Ready | Shadow and spacing bound to tokens. Colors live on the Tab Item atom. |
| C4 | Native Mappability | Ready | Maps cleanly to TabView / TabRow. |
| C5 | Interaction State Coverage | Partial | Active/inactive covered via Tab Item. No scrollable pattern for 5+ tabs. |
| C6 | Asset & Icon Quality | Ready | Children are Tab Item instances. |
| C7 | Code Connect Linkability | Pending | Not mapped. |
| tabsCount | Width | Node ID |
|---|---|---|
| 2 | 124px | 18482:33259 |
| 3 | 186px | 18482:33255 |
| 4 | 248px | 18482:33250 |
After the recommended restructure these 3 variants collapse to 1 flexible container accepting a list.
tabsCount is a variant property — Should be removed; the container should accept a list of Tab Items instead of exposing a fixed count enum. OpenTwo variants (expanded=yes, expanded=no) of the canonical Accordion with the title hardcoded to "Terms & Conditions" and the expanded body hardcoded to a fixed list of four voucher rules. It carries no unique schema, no unique tokens, and no unique behavior — every color references main/accordion/* tokens directly from the canonical Accordion.
Accordion primitive with a title prop and a content slot. Every time a product needs an accordion with a different title or body, the answer is to author a new instance — not to ship a new sibling. Replace this component with a documented usage example: EBAccordion(title: "Terms & Conditions", content: [...]).Two variants — collapsed and expanded. Both reuse the canonical Accordion's tokens (main/accordion/color/*) for header, label, chevron, and border; the expanded body packs four list rows bound to main/list-item/color/default/*. No tokens or structure are unique to this component.
Use the canonical EBAccordion with a product-defined title and body. No dedicated "Terms & Conditions" component is needed on either platform.
// Just use the canonical Accordion EBAccordion(title: "Terms & Conditions") { VStack(alignment: .leading, spacing: 8) { ForEach(rules) { rule in EBListItem(rule.text) } } }
// Just use the canonical Accordion EBAccordion(title = "Terms & Conditions") { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { rules.forEach { rule -> EBListItem(content = rule.text) } } }
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Layers match the canonical Accordion. |
| C2 | Variant & Property Naming | Ready | Only an expanded boolean — identical to the canonical Accordion's state axis. |
| C3 | Token Coverage | Ready | All colors bound to main/accordion/* and main/list-item/* tokens from the canonical components. |
| C4 | Native Mappability | N/A | Do not map. Use EBAccordion with a product-defined title and body. |
| C5 | Interaction State Coverage | Ready | Inherits expanded/collapsed from the canonical Accordion. |
| C6 | Asset & Icon Quality | Ready | Chevron and check icons are vector instances. |
| C7 | Code Connect Linkability | N/A | Not linkable — no native counterpart. Consumers call EBAccordion directly. |
16870:9288) with hardcoded title and body. No unique schema or tokens. DocumentedEBAccordion. Sets the precedent that product-specific compositions do not become DS siblings. OpenMulti-line text input with placeholder, filled, focus, error, and disabled states. 8 variants across State × isFilled. Mirrors Input Field's schema exactly — differs only in line count and a desktop resize-handle glyph. Native platforms treat multi-line as a single TextField modifier, not a distinct component.
main/text-area/*). SwiftUI exposes multi-line via axis: .vertical on TextField; Compose exposes it via singleLine=false. Fold into Input Field with a multiline / lineLimit prop so the DS maps 1:1 to the native primitive.Typical mobile contexts: feedback forms, message composers, notes, support request descriptions.
Toggle state and fill to see the text area update in real time.
multiline flag than by shipping a parallel component.label slot, no helperText/error-message slot, no characterCount slot — validation and labeling are pushed onto every consuming screen.isFilled=yes/no (C2, same anti-pattern Input Field already fixed). state=active instead of focused matches Input Field but diverges from the broader DS verb set. Tokens duplicated under main/text-area/* with identical values to main/input-field/*.expand-icon frame holds a fixed raster glyph rather than a swappable node.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | state=default | 1px #D7E0EF border, white bg. Resize glyph shown. |
| Active (Focused) | Yes | Yes | state=active | 2px #005CE5 border. Should rename to focused to match platform vocabulary. |
| Error | Yes | Yes | state=error | 2px #D61B2C border. No inline error-message slot. |
| Disabled | Yes | Yes | state=disabled | #EEF2F9 bg, border hidden, text #C2CFE5. |
- Boolean property uses Yes/No.
isFilled=yes/nocannot map to SwiftBoolor KotlinBooleanwithout a translation layer. Input Field already fixed this — Text Area should follow. C2 · Variant & Property Naming - Desktop resize-handle glyph baked into a mobile component. The
text-area icon(12×12px, bottom-right) mirrors the browser-onlyresize: bothaffordance. NativeTextField(axis: .vertical)/OutlinedTextField(maxLines=n)auto-grow without a user-facing handle — the glyph has no native equivalent and should not ship in mobile variants. C4 · Native Mappability - Resize handle is a raster PNG, once per state. Four separate PNG assets are referenced for the same 12×12px glyph. Even if the handle survives, it should be a single vector instance tinted by
main/text-area/color/{state}/icon-resizer. C6 · Asset & Icon Quality - No
labelorhelperTextslot. Labels and validation messages are handled by the consumer, so every screen re-implements the anatomy. Native multi-line text fields expose both as first-class parameters. C5 · Interaction State Coverage - No
characterCount/ limit affordance. Multi-line entry is the canonical use case for character limits (comments, reviews, message composers). There is no count slot and nomaxLengthhook — the DS cannot represent limit state today. C5 · Interaction State Coverage - Tokens duplicated under
main/text-area/*. Every value inmain/text-area/color/*mirrorsmain/input-field/color/*exactly. If Text Area folds into Input Field, this namespace collapses; if it stays, the two token sets should alias a sharedmain/field/*collection to prevent drift. C1 · Layer Structure & Naming - Code Connect mappings not registered. Blocked by property naming and the multi-line-vs-Input Field decision. Cannot register until the family shape is finalized. C7 · Code Connect Linkability
- Fold Text Area into Input Field as a
multiline/lineLimitprop. SwiftUI models this asTextField(text:, axis: .vertical).lineLimit(3...6); Compose models it asOutlinedTextField(singleLine=false, maxLines=6). Both are the same primitive with a single flag. Mirroring that in Figma collapses 8 variants into 0 net new variants on Input Field (add amultilineboolean to the existing 8-variant matrix) and removes the duplicated token namespace. Family - Rename
isFilledto usetrue/false. Same fix Input Field already shipped in 1.1.0. Required for SwiftBool/ KotlinBooleanmapping. Rename - Rename
state=activetostate=focused. Matches SwiftUI@FocusStateand ComposeFocusRequestervocabulary. Apply to Input Field and the whole Form Elements family at once so the rename lands once. Rename - Drop the desktop resize handle on mobile variants. If Text Area survives as a sibling, remove the
expand-iconframe — it has no native equivalent on iOS/Android. Keep it only if a web/desktop DS variant is in scope, gated behind a platform axis. Asset - Add a
helperTextslot and acharacterCountslot beneath the field. Multi-line is the canonical character-count surface. Expose a supporting-text row that can host either error copy, hint copy, or a count — matching Material 3'ssupportingText/counterpattern. If Text Area folds into Input Field, this slot lives on Input Field and serves both single- and multi-line. Slot - Alias
main/text-area/*to a sharedmain/field/*collection. If the family stays split for any reason, the two token sets must reference a single source so border/bg/placeholder/disabled colors never drift. Preferable outcome: deletemain/text-area/*outright after consolidation. Token
8 variants across 2 axes: state (default/active/error/disabled) × isFilled (yes/no). All share a 328px-wide container with 6px corner radius. Filled variants are 62px tall (2 lines of content); empty variants are 46px tall.
Idle state with gray border. Resize-handle glyph sits in the bottom-right regardless of fill.
Focused state with 2px blue border. Rename target: focused.
Validation error state with 2px red border. No inline error-message slot — copy is the consumer's responsibility.
Non-interactive state with gray fill and muted text. Border hidden.
All four color roles are bound to main/text-area/color/{state}/* tokens. Every value mirrors the equivalent main/input-field/color/* token — motivation for consolidation.
| Role | Token | DEFAULT | ACTIVE | ERROR | DISABLED |
|---|---|---|---|---|---|
| Border | text-area/color/{state}/border | #D7E0EF | #005CE5 | #D61B2C | hidden |
| Background | text-area/color/{state}/bg | #FFFFFF | #FFFFFF | #FFFFFF | #EEF2F9 |
| Text (filled) | text-area/color/{state}/text | #0A2757 | #0A2757 | #0A2757 | #C2CFE5 |
| Placeholder | text-area/color/{state}/placeholder | #C2CFE5 | #C2CFE5 | #C2CFE5 | #C2CFE5 |
| Resize glyph | text-area/color/{state}/icon-resizer | #D7E0EF | #D7E0EF | #D7E0EF | #D7E0EF |
| Property | Value | Token |
|---|---|---|
| Width | 328px (fill) | — |
| Height (empty) | 46px | — |
| Height (filled, 2 lines) | 62px | — |
| Padding top | 10px | space/space-10 |
| Padding bottom | 8px | space/space-8 |
| Padding left | 12px | space/space-12 |
| Padding right | 8px | space/space-8 |
| Corner radius | 6px | radius/radius-2 |
| Border (default) | 1px | — |
| Border (active/error) | 2px | — |
| Resize glyph | 12 × 12px | — |
| Text style | Primary/Multi-line Label/Light/Small | — |
| Font | Proxima Soft Semibold | font-family/font-primary |
| Size | 14px | font-size/font-size-20 |
| Line-height | 16px | line-height/leading-25 |
| Tracking | 0.25 | letter-spacing/tracking-wide |
Text Area is proposed to consolidate into EBInputField via a lineLimit / maxLines parameter. The install path below is shared with Input Field.
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:form-elements:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.form.* // Compose
Package not yet published. These are the planned distribution paths.
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| (text content) | text: Binding<String> | value: String | Derived from text content |
| isFilled (yes/no) | — | — | Derived from text.isEmpty; not a native param |
| (multi-line default) | axis: .vertical | singleLine=false | The trait that makes this a "text area" |
| (auto-grow range) | .lineLimit(3...6) | maxLines=6 | Replaces the desktop resize handle |
| state=default | — | — | Default idle state |
| state=active | @FocusState | interactionSource | Keyboard active |
| state=error | .ebError(true) | isError=true | Validation failed |
| state=disabled | .disabled(true) | enabled=false | Non-interactive |
| isExpandable | — | — | Desktop-only resize handle; no native equivalent |
EBInputField("Tell us more", text: $value, axis: .vertical) .lineLimit(3...6)
EBInputField( value = text, onValueChange = { text = it }, placeholder = "Tell us more", singleLine = false, maxLines = 6 )
EBInputField("Tell us more", text: $value, axis: .vertical) .lineLimit(3...6) .ebError(true)
EBInputField( value = text, onValueChange = { text = it }, placeholder = "Tell us more", singleLine = false, maxLines = 6, isError = true )
EBInputField("Tell us more", text: $value, axis: .vertical) .lineLimit(3...6) .disabled(true)
EBInputField( value = text, onValueChange = { text = it }, placeholder = "Tell us more", singleLine = false, maxLines = 6, enabled = false )
| Requirement | iOS | Android |
|---|---|---|
| Minimum touch target | 44 x 44 pt (per-line height 22pt, container ≥44pt) | 48 x 48 dp |
| Accessibility label | .accessibilityLabel("Comment") | contentDescription |
| Error announcement | VoiceOver reads error via .accessibilityValue | TalkBack reads error via semantics { error() } |
| Character-count announcement | Announce remaining via .accessibilityValue when a limit is set | Expose via supportingText semantics |
Do
Use for free-form responses expected to exceed one line — feedback, comments, messages, notes.
Don't
Use for short structured inputs (name, phone, code) — Input Field's single-line default is more appropriate and faster to fill.
Do
Pair with a visible label above the field and a helper-text row below for character counts or format hints.
Don't
Rely on the desktop resize handle on mobile — mobile fields auto-grow within lineLimit/maxLines and the handle has no native behavior.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Partial | Duplicate token namespace main/text-area/* mirrors main/input-field/* exactly. Text layer structure is clean. |
| C2 | Variant & Property Naming | Fail | isFilled=yes/no (same anti-pattern Input Field already resolved). state=active should be focused. |
| C3 | Token Coverage | Ready | All colors bound to main/text-area/color/*. Spacing and radius tokens resolved. |
| C4 | Native Mappability | Rework | Exists as a distinct component but native platforms treat multi-line as a single TextField with axis: .vertical / singleLine=false. Desktop resize handle has no native equivalent. |
| C5 | Interaction State Coverage | Partial | All 4 interaction states present. Missing slots: label, helper/error text, character count. |
| C6 | Asset & Icon Quality | Fail | Resize glyph is a raster PNG referenced four times (once per state) instead of a single vector instance. |
| C7 | Code Connect Linkability | Not Mapped | Blocked by the consolidation decision and property renames. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Fail | isFilled=yes/no cannot map to native booleans |
| Component identity | Fail | Native platforms have no standalone TextArea primitive; consolidation into Input Field is required first |
| Native component file | Pending | Proposed target: EBInputField with multi-line flag |
4 state values × 2 isFilled values.
| state | isFilled | Height | Node ID |
|---|---|---|---|
| default | yes | 62px | 3070:21242 |
| default | no | 46px | 3070:21239 |
| active | yes | 62px | 3070:21243 |
| active | no | 46px | 3070:21238 |
| error | yes | 62px | 3070:21244 |
| error | no | 46px | 3070:21240 |
| disabled | yes | 62px | 3070:21241 |
| disabled | no | 46px | 3070:21237 |
state (default/active/error/disabled) × isFilled (yes/no). Multi-line sibling of Input Field within the Form Elements group. Documentedmultiline / lineLimit prop to match SwiftUI axis: .vertical and Compose singleLine=false. OpenisFilled=yes/no instead of true/false. Same anti-pattern Input Field already resolved. Openmain/text-area/color/* values mirror main/input-field/color/* exactly; candidate for aliasing or deletion after consolidation. OpenAn app-level navigation title bar used at the top of every screen. Includes an iOS status bar stub (44px), a centered title row with optional leading icon (back arrow), trailing icon, leading control ("Done" text), subtext (URL), and an optional title block for large headers. 20 variants across 5 boolean properties. Background is brand blue (#1972F9), all text and icons white.
Contexts are illustrative. Final screens will reference actual GCash patterns.
Toggle properties to see the title bar update in real time.
yes/no instead of true/false (C2). leading control only available when leading icon=yes and trailing icon=no -- implicit dependency not expressed in property schema.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | 5 boolean properties | Navigation bar. No interaction states beyond tap targets on icons and control text. |
Title Bar is a navigation container. Interaction states (pressed, focused) apply to the individual tap targets (leading icon, trailing icon, leading control) rather than the bar itself. These states are not represented as separate variants in the component.
- Boolean properties use
yes/nostrings.leading icon,trailing icon,leading control,subtext,title block— all incompatible with SwiftBool/ KotlinBooleanfor Code Connect. C2 · Variant & Property Naming - Trailing icon is a placeholder RECTANGLE. The 24×24
icon-placeholderblocks instance swap — native icon slot mapping can't be wired until this becomes a swappable Icon instance. C6 · Asset & Icon Quality - Code Connect mappings not registered. Blocked until boolean values and placeholder rectangles are fixed. C7 · Code Connect Linkability
- Rename boolean values from
yes/nototrue/false. Direct native boolean mapping eliminates the string-to-bool layer when wiring Code Connect. Rename - Replace trailing icon placeholder with a real Icon instance. Drop in an actual icon from the DS library (e.g. more/ellipsis, share, search) so product teams can instance-swap without editing the master. Slot
- Make
leading controldependency explicit. Today it requiresleading icon=yesandtrailing icon=no— document it in the spec or promote it to a separate property so the constraint is enforced, not assumed. Docs - Add a dark / transparent variant. For screens with hero images or gradient backgrounds (home, campaign pages, onboarding), the current opaque bar is wrong — a transparent variant lets the hero breathe through. Property
- Document the
showAssetproperty. Currently only available whentitle block=yes; its purpose (background image behind the title) should be explicitly described in the component spec. Docs
20 variants across 2 structural configurations: Standard (no title block, 10 variants) and With Title Block (titleBlock=yes, 10 variants). Each configuration has the same 10 boolean property combinations for leading icon, trailing icon, leading control, and subtext.
Standard title bar without title block. Status bar (44px) + title row with optional icons, control, and subtext. Height ranges from 84px to 100px depending on subtext.
Title bar with expanded header block (72px) below the title row. Used for screens with prominent section headers. Adds "Header" text at 26px Semibold.
Single color scheme -- no appearance modes. All colors bound to main/title-bar/color/ tokens. Display/navigation component with no state-driven color changes.
| Role | Token | Value |
|---|---|---|
| Background | main/title-bar/color/bg | #1972F9 |
| Title label | main/title-bar/color/label-title | #FFFFFF |
| Header label | main/title-bar/color/label-header | #FFFFFF |
| Subtext / URL | main/title-bar/color/label-url | #F6F9FDCC (80% opacity) |
| CTA text | main/title-bar/color/label-cta | #FFFFFF |
| Icon | main/title-bar/color/icon | #FFFFFF |
| Property | Value |
|---|---|
| Status bar height | 44px |
| Title row padding H | 20px |
| Title row padding V | 12px |
| Leading icon size | 24 x 24 |
| Trailing icon size | 24 x 24 |
| Title block height | 72px |
| Title block padding H | 24px |
| Total height (no subtext, no block) | ~84px |
| Total height (with subtext, no block) | ~100px |
| Total height (with block) | ~156--172px |
| Layer | Text Style | Font | Size | Tracking | Line-height |
|---|---|---|---|---|---|
| Title | Primary/Label/Light/Base | HeyMeow Rnd Semibold | 16px | 0.25px | 16px |
| Subtext | Primary/Label/Light/Fine | HeyMeow Rnd Semibold | 12px | 0.5px | 12px |
| Header | Primary/Headlines/Light/Area | HeyMeow Rnd Semibold | 26px | 0.85px | 31px |
| CTA (control) | Primary/Label/Light/Small | HeyMeow Rnd Semibold | 14px | 0.25px | 14px |
iOS -- Swift Package Manager
// In Xcode: File -> Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android -- Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:titlebar:1.0.0") }
Import
import EastBlueDS // SwiftUI import com.eastblue.ds.titlebar.* // Compose
Package not yet published. These are the planned distribution paths.
| Figma Property | SwiftUI | Compose | Notes |
|---|---|---|---|
| leading icon (yes/no) | .ebLeadingIcon(Image?) | leadingIcon: @Composable (() -> Unit)? | Back arrow, optional |
| trailing icon (yes/no) | .ebTrailingIcon(Image?) | trailingIcon: @Composable (() -> Unit)? | Action icon, optional |
| leading control (yes/no) | .ebLeadingControl("Done") | leadingControlText: String? | CTA text, replaces trailing icon |
| subtext (yes/no) | .ebSubtext("m.gcash.com") | subtext: String? | URL or secondary text below title |
| title block (yes/no) | .ebTitleBlock("Header") | titleBlock: String? | Large header below title row |
EBTitleBar("Send Money")
EBTitleBar( title = "Send Money" )
EBTitleBar("Send Money") .ebLeadingIcon(Image(systemName: "arrow.left"))
EBTitleBar( title = "Send Money", leadingIcon = { Icon(Icons.Default.ArrowBack, "Back") } )
EBTitleBar("GCash") .ebLeadingIcon(Image(systemName: "arrow.left")) .ebTrailingIcon(Image(systemName: "ellipsis")) .ebSubtext("m.gcash.com") .ebTitleBlock("My Wallet")
EBTitleBar( title = "GCash", leadingIcon = { Icon(Icons.Default.ArrowBack, "Back") }, trailingIcon = { Icon(Icons.Default.MoreVert, "More") }, subtext = "m.gcash.com", titleBlock = "My Wallet" )
EBTitleBar("Edit Profile") .ebLeadingIcon(Image(systemName: "arrow.left")) .ebLeadingControl("Done")
EBTitleBar( title = "Edit Profile", leadingIcon = { Icon(Icons.Default.ArrowBack, "Back") }, leadingControlText = "Done" )
| Requirement | iOS | Android |
|---|---|---|
| Minimum touch target | 44 x 44 pt (icons and control) | 48 x 48 dp (icons and control) |
| Back button label | .accessibilityLabel("Back") | contentDescription="Navigate back" |
| Trailing icon label | .accessibilityLabel("More options") | contentDescription="More options" |
| Heading semantics | .accessibilityAddTraits(.isHeader) on title | semantics { heading() } on title |
Do
Use EBTitleBar as the top-level navigation element on every screen. Keep the title short and descriptive.
Don't
Nest a title bar inside scrollable content or use it as a section header within a page -- use a section heading component instead.
Do
Use the title block for high-level section headers like "My Wallet" or "Dashboard" where the large text reinforces the current context.
Don't
Show both trailing icon and leading control simultaneously -- they occupy the same trailing slot. Use one or the other.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic layer names: title, Title Bar, title-block, Leading Icon, Placeholder. |
| C2 | Variant & Property Naming | Needs Refinement | All 5 boolean properties use yes/no instead of true/false. leading control has implicit dependency on other properties. |
| C3 | Token Coverage | Ready | All 6 color roles bound to main/title-bar/color/ tokens. |
| C4 | Native Mappability | Ready | Maps to NavigationBar (iOS) / TopAppBar (Android, Material 3). |
| C5 | Interaction State Coverage | Ready | Navigation bar -- no interaction states needed beyond individual tap targets on icons and control. |
| C6 | Asset & Icon Quality | Needs Refinement | Trailing icon uses icon-placeholder RECTANGLE instead of a swappable icon instance. |
| C7 | Code Connect Linkability | Needs Refinement | No CLI mappings registered yet. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Needs Fix | All booleans use yes/no -- must be renamed to true/false before Code Connect mapping |
| Asset quality | Needs Fix | Trailing icon placeholder RECTANGLE needs replacement with icon instance |
| State coverage | Ready | Navigation bar -- no interaction states needed |
| Native component file | Pending | EBTitleBar.swift / EBTitleBar.kt not yet created |
5 boolean properties (leading icon, trailing icon, leading control, subtext, title block) with implicit constraints yield 20 variants: 10 without title block + 10 with title block.
| title block | Combinations covered | Count |
|---|---|---|
| no | 10 combos of leading/trailing icon + leading control + subtext | 10 |
| yes | Same 10 combos with title block enabled | 10 |
View full property combination breakdown (20 rows)
| leading icon | trailing icon | leading control | subtext | title block | Node ID |
|---|---|---|---|---|---|
| no | no | no | no | no | 23:175149 |
| no | no | no | yes | no | 23:175365 |
| no | yes | no | no | no | 23:175415 |
| no | yes | no | yes | no | 23:175427 |
| yes | yes | no | no | no | 23:175377 |
| yes | yes | no | yes | no | 23:175389 |
| yes | no | no | no | no | 23:175487 |
| yes | no | no | yes | no | 23:175499 |
| yes | no | yes | no | no | 23:175449 |
| yes | no | yes | yes | no | 23:175461 |
| no | no | no | no | yes | 23:175159 |
| no | no | no | yes | yes | 23:175169 |
| no | yes | no | no | yes | 23:175179 |
| no | yes | no | yes | yes | 23:175189 |
| yes | yes | no | no | yes | 23:175199 |
| yes | yes | no | yes | yes | 23:175209 |
| yes | no | no | no | yes | 23:175219 |
| yes | no | no | yes | yes | 23:175229 |
| yes | no | yes | no | yes | 23:175239 |
| yes | no | yes | yes | yes | 23:175249 |
main/title-bar/color/ tokens. Documentedleading icon, trailing icon, leading control, subtext, title block) use yes/no instead of true/false. Incompatible with Swift Bool and Kotlin Boolean for Code Connect mapping. Openicon-placeholder is a 24x24 RECTANGLE instead of a swappable icon instance from the DS icon library. Blocks native icon slot mapping. OpenA transient overlay notification with an inline action button. 4 variants across Type (Default / Light) × Description (yes / no). Modeled as a sibling of the base Toast solely to add the button — which should be an optional action slot on Toast itself. Width 330 (not 312 like base Toast), action surface uses the deprecated Button - Small/XS instance.
action?: EBToastAction (label + callback) and supportingText?: String (the 10/15 BarkAda second line). Align width with base Toast (312 vs. 330 today) and swap the deprecated Button - Small/XS for Button - XSmall. Covers the "Undo / Retry / View" use cases and collapses two components into one.The actionable toast appears after reversible operations — "Transfer sent · Undo", "Message failed · Retry", "Photo uploaded · View". The action button sits right-aligned, tappable without dismissing the toast. Auto-dismiss is suppressed while an action is present.
The description toggle switches between a two-line toast (label + supporting text, 12 px vertical padding) and a single-line toast (label only, 8 px vertical padding). Action button appearance flips with type: white-on-navy for default, blue-on-white for light.
.[DEPRECATED] Button - Small/XS instance (scheduled for deletion). When that source is removed, this component breaks. Owns its surface tokens, but its action surface is borrowed from a deprecated source.type=default|light here vs. base Toast's type=default|pending|error and theme=default|light|dark — same axis name, incompatible value sets. Width is 330; base Toast is 312.| Behavior | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Action tap | Yes | Yes | Button instance embedded | The deprecated Button - Small/XS is the action surface. Only one fixed appearance; consumers can't disable or show a loading spinner on the action. |
| Whole-container tap | Ambiguous | Ambiguous | Root is a <button> in some variants | The default, description=no variant wraps the whole toast in a button element, overlapping the inner action — tap target is unclear. |
| Auto-dismiss when action present | Missing | Missing | Not annotated | Toasts with actions should stay visible until the action is taken or explicitly dismissed. Not spec'd. |
| Action loading / disabled state | Missing | Missing | Not modeled | "Retry" actions often need a loading state after tap. No provision in the component. |
| A11y — action label | Generic | Generic | Text "Label" | The default text in the instance is literally "Label". Needs a content contract and an accessibility label override. |
- Separate component for a button slot that should be a prop on the base Toast. Material's Snackbar, SwiftUI's
.alert(actions:), and every other mature DS handle this as an optional action parameter — not a sibling record. Maintaining two components doubles the surface area of every future change and invites drift (different widths, different type sets). C1 · Layer Structure & Naming - Axis names + values drift from the base Toast. Base Toast exposes
type=default | pending | error+theme=default | light | dark. This sibling exposestype=default | light— same axis name, narrower value set, collides on meaning. Consumers wiring Code Connect can't treat them as the same prop. C2 · Variant & Property Naming description=yes|nois a slot + sizing flag bundled into a string. When description is present, vertical padding grows from 8 to 12 and a 4 px gap is inserted below the label. The trigger should be the presence of supporting-text content (supportingText?: String), not a hard-coded variant. C2 · Variant & Property Naming- Action surface uses the deprecated Button - Small/XS. The embedded Button instance (node
21:164490) is marked DEPRECATED in Figma, slated for deletion. When that source is removed, every variant of this toast breaks. Must re-link to Button - XSmall. C4 · Native Mappability - No icon axis at all. Base Toast has
With Icon=yes | no. This sibling drops the axis entirely — you can't have an actionable toast with a leading checkmark or error glyph. Merging into Toast recovers the icon automatically. C6 · Asset & Icon Quality - Whole-container tap overlaps the action button. The
default, description=novariant wraps the entire toast in a<button>element, while the inner action is also a button — two conflicting tap targets stacked. Behavior is undefined when the user taps the non-action area. C5 · Interaction State Coverage - Action has no states. No pressed, disabled, or loading state on the action. "Retry" actions commonly need a spinner after tap; destructive "Undo" often needs to grey out during processing. Not modeled. C5 · Interaction State Coverage
- Width 330 vs. base Toast 312. Inconsistent with the base Toast's fixed width. A single consolidated component picks one width (recommend 312, matching the rest of the family). C1 · Layer Structure & Naming
- Code Connect mappings not registered. Blocked on consolidation — there should be no separate Code Connect entry; the action slot maps to the base Toast's
actionparameter. C7 · Code Connect Linkability
- Consolidate into the base Toast. Remove Toast - With Button from the family. Base Toast picks up two new optional slots:
supportingText?: String(the 10/15 BarkAda second line) andaction?: EBToastAction(label + callback, with optional loading and disabled states). One component, covers every use case today and the ones this sibling misses (actionable error, actionable with icon). Family - Migrate the action surface to Button - XSmall. The embedded Button - Small/XS is marked deprecated in Figma. Rebind the action instance to the canonical Button - XSmall before the deprecated source is deleted — otherwise every variant breaks. Composition
- Replace
description=yes|nowith a supporting-text slot. Promote the second text line to an optional content slot. The padding and gap changes follow from the slot being populated — no duplicate variants required. Slot - Normalize width to 312. Match the base Toast. One width across the family. Property
- Define the action's state contract. Spec pressed / disabled / loading states on the action slot so "Retry" and "Undo" flows can reflect processing state. State
- Resolve the whole-container tap conflict. Decide: either the toast is dismiss-on-tap (drop the action as the only interactive surface), or the action owns the only tappable region. Pick one and drop the root
<button>wrapper from the other variants. State - Document auto-dismiss suppression when an action is present. A toast with an action stays visible until the user taps the action or explicitly dismisses. Call this out as a usage note. Docs
- Document the A11y announcement mapping. Action label feeds
accessibilityLabel(iOS) /contentDescription(Android). Live region polite for neutral, assertive if consolidated into a destructive Toast. A11y
813:31117The two-line dark toast. Label (14 px bold) above supporting text (10 px BarkAda Medium, 80% white), action button right-aligned and bottom-aligned. Used for reversible success moments that need more context — "Transfer sent · to Juan Dela Cruz".
defaultyesButton - Small/XS (deprecated)| TYPE | ROLE | TOKEN | VALUE |
|---|---|---|---|
| Default | bg | default/bg | #0A2757 |
| label | default/label | #FFFFFF | |
| description | default/description | #F6F9FDCC | |
| border | default/border | #E5EBF4 | |
| Light | bg | light/bg | #FFFFFF |
| label | light/label | #0A2757 | |
| description | light/description | #445C85 | |
| border | light/border | #E5EBF4 | |
| Action (both) | button bg (default/light) | button-v1/default/background | #005CE5 |
| button bg (on dark) | button-v1/default/background-primary | #FFFFFF |
3301612881 solid token2442416 × 8/799 (pill)Primary/Multi-line Label/SmallProxima Soft Bold14 / 16 · +0.25Secondary/Default/Small CaptionBarkAda Medium10 / 15 · +0Primary/Label/SmallProxima Soft Bold14 / 14 · +0.2527:53213Same two-line layout, inverted surface. White bg, navy label, slate supporting text, blue-on-white action button. Used on dark backgrounds or when the surrounding screen is already high-contrast.
lightyesvariant=default (blue)33016 × 121 · #E5EBF4#FFFFFF / #005CE5813:31125The compact single-line toast with action. 8 px vertical padding, label only. Used for short reversible actions — "Copied · Undo".
defaultno<button> (whole container)33016 × 82466 px fixed slot27:53225The compact light-surface toast. Single-line label, blue action button, white bg.
lightno<div> (non-interactive outer)Primary/Multi-line Label/SmallProxima Soft Bold14 / 16 · +0.25After consolidation this component is absorbed by the base Toast. The install + import remain the same; the action becomes a parameter.
.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBToast import com.gcash.designsystem.components.EBToastAction
This component collapses into the base Toast. Figma properties map to the consolidated Toast API, not to a standalone EBToastWithButton.
| Figma (today) | Figma (proposed — on Toast) | SwiftUI | Compose |
|---|---|---|---|
Type: default | light | theme: light | dark (shared with Toast) | .ebToastTheme(.dark) | theme: EBToastTheme |
Description: yes | no | supportingText?: String (slot) | supportingText: String? | supportingText: String? |
| (embedded Button - Small/XS — deprecated) | action?: ToastAction (slot) | action: EBToastAction? | action: @Composable (() -> Unit)? |
| (implicit label text) | message: String | message: String | message: String |
| (no icon axis) | leadingIcon?: Icon (inherited from Toast) | leadingIcon: Image? | leadingIcon: @Composable (() -> Unit)? |
ios/Components/Toast/EBToast.swift— absorbs the action slotios/Components/Toast/EBToastAction.swift— action model (label + onTap + optional state)android/components/toast/EBToast.kt— absorbs the action slot (wraps Material 3Snackbar)
No dedicated EBToastWithButton file — the component is removed from the family after consolidation.
// Undo pattern — single-line, dark surface EBToast( message: "Message deleted", action: EBToastAction("Undo", onTap: undo) ) .ebToastTheme(.dark) // Retry pattern — with supporting text, light surface EBToast( message: "Couldn't send money", supportingText: "Check your connection and try again.", action: EBToastAction("Retry", onTap: retry) ) .ebToastTheme(.light) // Action with loading state (after tap) EBToast( message: "Couldn't send money", action: EBToastAction("Retry", isLoading: isRetrying, onTap: retry) )
// Undo pattern — single-line, dark surface EBToast( message = "Message deleted", theme = EBToastTheme.Dark, action = { EBToastAction("Undo", onClick = onUndo) } ) // Retry pattern — with supporting text, light surface EBToast( message = "Couldn't send money", supportingText = "Check your connection and try again.", theme = EBToastTheme.Light, action = { EBToastAction("Retry", onClick = onRetry) } ) // Action with loading state EBToast( message = "Couldn't send money", action = { EBToastAction( "Retry", isLoading = isRetrying, onClick = onRetry ) } )
| Requirement | iOS | Android |
|---|---|---|
| Action label | Action passes accessibilityLabel through to the inner Button. Default: the visible label. | Action passes contentDescription to the inner Button. Default: the visible label. |
| Live region | Polite announcement; use the Toast's appearance to decide (destructive → assertive). | LiveRegionMode.Polite by default; assertive for destructive. |
| Suppress auto-dismiss | When action is non-nil, host overlay keeps the toast on screen until the action is tapped or the user swipes. | SnackbarDuration.Indefinite when an action is present. |
| Action loading state | Swap label for a ProgressView; keep the button reachable for VoiceOver (don't disable mid-announcement). | Swap label for a CircularProgressIndicator; set enabled=false after the state is announced. |
| Tap target | Action button must have a ≥ 44 × 44 hit area; the 24 px pill extends via .contentShape if visual size is smaller. | Action button must have a ≥ 48 dp touch target; use Modifier.minimumInteractiveComponentSize(). |
- Use the action slot for reversible operations: Undo, Retry, View.
- Keep the action label to one word where possible (≤ 10 characters).
- Suppress auto-dismiss while an action is visible — let the user decide.
- Show a loading state on the action button during async retries.
- Don't use an action toast for destructive operations that can't be reversed.
- Don't stack more than one action in the slot — use a Modal if you need multiple.
- Don't make the entire toast tappable while an action is present — conflicts with the action's hit target.
- Don't use the deprecated Button - Small/XS instance. Rebind to Button - XSmall before consolidation.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Separate component for a property slot. Consolidate into base Toast; normalize width from 330 to 312. |
| C2 | Variant & Property Naming | Rework | type values drift from base Toast; description=yes|no should be a content slot. |
| C3 | Token Coverage | Ready | Surface, label, description, border tokens all bound via main/toast/color/{default|light}/*. Action uses comp/button-v1/default/*. |
| C4 | Native Mappability | Rework | Action surface uses the deprecated Button - Small/XS; maps cleanly to Snackbar's action slot once migrated. |
| C5 | Interaction State Coverage | Rework | No action states (pressed / disabled / loading); whole-container tap overlaps the inner action. |
| C6 | Asset & Icon Quality | Rework | No leading-icon axis at all — consolidation recovers it from base Toast. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on consolidation — no standalone Code Connect entry; action maps to base Toast's action parameter. |
Axes: Type (2) × Description (2)=4 variants. Flat matrix — no collapsed axes, no illegal combinations. Every variant embeds the deprecated .[DEPRECATED] Button - Small/XS instance.
| # | Node | Type | Description | Dimensions | Notes |
|---|---|---|---|---|---|
| 1 | 813:31117 | default | yes | 330 × 74 | Root is a <button> element |
| 2 | 27:53213 | light | yes | 330 × 74 | Root is a <div> element |
| 3 | 813:31125 | default | no | 330 × 41 | Root is a <button> element (tap conflict) |
| 4 | 27:53225 | light | no | 330 × 41 | Root is a <div> element |
action?: EBToastAction. Width drifts from base (330 vs. 312). Opentype=default|light here vs. type=default|pending|error + theme=default|light|dark on base Toast. description=yes|no should be supportingText?: String. Open.[DEPRECATED] Button - Small/XS (node 21:164490), slated for deletion Aug 22, 2025. Rebind to Button - XSmall before migration. Opendescription=no variants wrap the root in a <button> overlapping the inner action. No pressed/disabled/loading states for the action. OpenWith Icon axis that base Toast exposes. Consolidation recovers it. OpenA transient overlay notification. 16 variants spanning Type (Default / Pending / Error) × Theme (Default / Light / Dark) × With Icon × Large Label. Floats above the UI to acknowledge an action, surface an error, or indicate pending work — auto-dismisses after a short duration. Sibling component Toast - With Button models the same primitive with an inline action; the two should consolidate.
action slot. Split the overloaded theme axis into appearance=neutral | destructive | pending + theme=light | dark. Replace Large Label with size=small | base. Replace the Pending placeholder circle with a real spinner instance. Add a dismiss contract (swipe + auto-duration).Toasts float over the app screen — not inline with content. Success toasts confirm completed actions ("Transfer sent"), pending toasts acknowledge background work ("Uploading…"), and error toasts surface failures that don't block the flow. They auto-dismiss after ~3 seconds unless swiped.
Type into the message to stress-test the layout. The theme dropdown gates the type options: Error only pairs with theme=default (its destructive surface), while Default / Pending pair with dark or light. That coupling is an Open Issue — see Design Recommendations.
icon-placeholder gray circle instead of a real spinner — consumers can't drop in a live progress indicator without editing the master.theme axis mixes appearance (light/dark surface) with semantic status — Error forces theme=default and loses the dark/light choice. Large Label is really a size axis phrased as a content flag.| Behavior | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Show / auto-dismiss | Yes | Yes | Not modeled | Toasts appear on a host overlay and auto-dismiss after a duration (~3s short, ~5s long). Host-screen concern — no visual state variant needed. |
| Swipe to dismiss | Missing | Missing | Not annotated | Standard gesture on both platforms. Not called out in the component spec. |
| Tap to dismiss | Missing | Missing | Not annotated | Pending variants already wrap the container in a button element in the Figma code — but no interaction callback is documented. |
| Pending spinner animation | Placeholder | Placeholder | Gray circle | Pending icon is a static gray circle (icon-placeholder) — should be an animated spinner (ProgressView / CircularProgressIndicator). |
| A11y announcement | Implicit | Implicit | Not annotated | Error toasts should announce as assertive; default/pending as polite. Not spec'd. |
- Two components model one primitive. Toast + Toast - With Button differ only in the presence of an action button. Action belongs on this component as an optional slot, not a separate family member. Doubling the surface area of every future change. C1 · Layer Structure & Naming
themeaxis overloaded with status. Values aredefault | light | darkbutdefaultis only valid whentype=error(it paints the destructive red surface). Real axes areappearance=neutral | destructive | pending×theme=light | dark. Current schema blocks light/dark error variants. C2 · Variant & Property NamingLarge Labelis a size flag, not a content flag. The two values change padding, font size, and line-height — this is a size axis. Rename tosize=small | base(orcompact/regular) so the schema reads correctly. C2 · Variant & Property Naming- Booleans use
yes/nostrings.With Icon/Large Label— blocks direct SwiftBool/ KotlinBooleanmapping. C2 · Variant & Property Naming - Pending type uses a placeholder icon. 16 × 16 (small) and 24 × 24 (base) gray
icon-placeholdercircles instead of an animated spinner. No instance of a ProgressView / CircularProgressIndicator — native consumers can't wire up a real loading state. C6 · Asset & Icon Quality - No swipe-to-dismiss or auto-duration contract. Standard toast behaviors on both iOS and Android. Neither is annotated in the Figma component spec — engineers have to guess the duration budget and gesture handling. C5 · Interaction State Coverage
- No native primitive mapping documented. SwiftUI has no first-party toast (pre-iOS 17); Compose uses SnackbarHost + Snackbar. The component doesn't call out either mapping — dev-handoff guessing game. C4 · Native Mappability
- Code Connect mappings not registered. Blocked on family consolidation and axis cleanup. C7 · Code Connect Linkability
- Consolidate Toast + Toast - With Button. Target schema:
EBToast(message, appearance=.neutral | .destructive | .pending, theme=.light | .dark, size=.small | .base, leadingIcon?: Icon, action?: EBToastAction). Remove theWith Buttoncomponent from the family. Family - Split
themeintoappearance+theme.appearance=neutral | destructive | pendingcontrols semantic status + surface palette;theme=light | darkcontrols the neutral-surface contrast mode. Unlocks light/dark destructive variants and matches every other DS component's mental model. Property - Rename
Large Labeltosize=small | base. Matches the actual axis (padding, font size, spacing) and mirrors Button / Alert sizing. Rename - Normalize booleans to
true/false.With Iconand any remaining flags. Then renameWith IcontoleadingIcononce promoted to a Slot. Rename - Replace Pending placeholder with a real spinner. Instance-swap a ProgressView / CircularProgressIndicator (or the DS Spinner component when it ships) into the leading-icon slot for
appearance=pending. Same slot covers the checkmark forappearance=neutraland the X forappearance=destructive. Asset - Promote the leading icon to a swappable Slot. Adopt Figma Slots so consumers can drop in any Icon from the DS icon library. Default slot content per appearance: checkmark / spinner / error glyph. Slot
- Add an optional action slot.
action?: EBToastAction— takes label + callback. Covers the "Undo", "Retry", "View" use cases and replaces the need for Toast - With Button entirely. Slot - Document duration + dismiss behavior. Annotate default duration (3s short / 5s long), swipe-to-dismiss, tap-to-dismiss, and the
onDismisscallback contract in the component spec. Not a visual variant — a usage note. Docs - Document the A11y live-region mapping. Error toasts announce as assertive (
UIAccessibility .high/LiveRegionMode.Assertive); default + pending as polite. Spell this out so engineers wire the right roles. A11y
27:53136The canonical success toast. Dark navy surface, white text, leading checkmark icon. Used across the app to confirm completed actions (transfers, settings saved, uploads done).
defaultdarkyesyes| MODE | ROLE | TOKEN | VALUE |
|---|---|---|---|
| Default (dark) | bg | default/bg | #0A2757 |
| label | default/label | #FFFFFF | |
| icon | default/icon | #FFFFFF | |
| Light | bg | light/bg | #FFFFFF |
| label | light/label | #0A2757 | |
| icon | light/icon | #0A2757 | |
| Destructive (error) | bg | destructive/bg | #D61B2C |
| label | destructive/label | #FFFFFF | |
| icon | destructive/icon | #FFFFFF | |
| Border (all modes) | {mode}/border | neutral #E5EBF4 · destructive #F4C7C9 | |
31212120 (12 on inner offset)81 solid token0 1 3 rgba(232,238,242,.79)824 × 2416 × 167Primary/Label/Light/SmallPrimary/Multi-line Label/Light/FineProxima Soft Semibold14 / 14 · +0.2512 / 14 · +0.527:53154Destructive surface for errors. Red bg, white label, leading X glyph. Only paints when theme=default — the current schema doesn't allow a light/dark error variant.
errordefaultyesyes31212 × 1281 · #F4C7C9Close (X)24 × 243424:1308Acknowledges in-flight work. Ships a placeholder gray circle today (icon-placeholder) — should be an animated spinner. Container is wrapped in a <button> element in the Figma code, suggesting tap-to-dismiss is intended but not documented.
pendingdarkyesyesicon-placeholder circle#C2C6CF (static)ProgressView / CircularProgressIndicator24 × 2416 × 1627:53196The compact, text-only toast. No icon, smaller type (12/14, tracking +0.5). Used when the message is short and the context is unambiguous ("Copied", "Saved").
defaultdarknonoPrimary/Multi-line Label/Light/FineProxima Soft Semibold12 / 14 · +0.5.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBToast
The proposed schema collapses Toast + Toast - With Button into one API. Action becomes an optional slot, theme splits into appearance + theme, and Large Label becomes size.
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
Type: default | pending | error | appearance: neutral | pending | destructive | appearance: EBToastAppearance | appearance: EBToastAppearance |
Theme: default | light | dark (overloaded) | theme: light | dark (neutral + pending only) | .ebToastTheme(.dark) | theme: EBToastTheme |
Large Label: yes | no | size: small | base | .controlSize(.small / .regular) | size: EBToastSize |
With Icon: yes | no | leadingIcon?: Icon (slot) | leadingIcon: Image? | leadingIcon: @Composable (() -> Unit)? |
| (implicit) | message: String | message: String | message: String |
| (separate component) | action?: ToastAction | action: EBToastAction? | action: @Composable (() -> Unit)? |
| (not modeled) | duration: short | long | duration: EBToastDuration | duration: EBToastDuration |
| (not modeled) | onDismiss?: () -> Void | onDismiss: (() -> Void)? | onDismiss: (() -> Unit)? |
ios/Components/Toast/EBToast.swiftios/Components/Toast/EBToastManager.swift— presentation overlayandroid/components/toast/EBToast.kt— wraps Material 3SnackbarHost
// Neutral success toast — most common form EBToast( message: "Transfer completed", appearance: .neutral, leadingIcon: Image("checkmark.circle") ) .ebToastTheme(.dark) // Pending — spinner instead of static icon EBToast( message: "Uploading…", appearance: .pending ) // Error with retry action (absorbs Toast - With Button) EBToast( message: "Couldn't send money", appearance: .destructive, action: EBToastAction("Retry", onTap: retry) ) // Presentation — shown via manager overlay EBToastManager.shared.present(toast, duration: .short)
// Neutral success toast EBToast( message = "Transfer completed", appearance = EBToastAppearance.Neutral, theme = EBToastTheme.Dark, leadingIcon = { Icon(EBIcons.CheckCircle, contentDescription = null) } ) // Pending — animated spinner EBToast( message = "Uploading…", appearance = EBToastAppearance.Pending, theme = EBToastTheme.Dark ) // Error with retry action EBToast( message = "Couldn't send money", appearance = EBToastAppearance.Destructive, action = { EBToastAction("Retry", onClick = onRetry) } ) // Presentation — wraps SnackbarHostState snackbarHostState.showSnackbar("Transfer completed")
| Requirement | iOS | Android |
|---|---|---|
| Live region — error | Post UIAccessibility.Notification.announcement with .high priority on present. | Modifier.semantics { liveRegion=LiveRegionMode.Assertive } on the Snackbar container. |
| Live region — neutral / pending | Post announcement with default priority. | LiveRegionMode.Polite. |
| Minimum duration | Short ≥ 3s, long ≥ 5s; extend for longer messages per iOS HIG. | SnackbarDuration.Short / Long (Material 3 defaults). |
| Action button label | Action slot owns its own accessibilityLabel. | Action slot owns its own contentDescription. |
| Dismiss gesture | Swipe horizontally to dismiss; respect reduce-motion for the slide-out animation. | Swipe to dismiss built into Snackbar; honor TalkBackUserTouchExplorationEnabled to extend duration. |
- Use toasts for transient confirmation — success, error, and in-flight work.
- Keep the message to one line (or two lines max for the small size).
- Use the action slot for reversible operations (Undo, Retry, View).
- Pair destructive toasts with assertive live-region announcements.
- Don't use toasts for blocking errors — use a Modal or Alert instead.
- Don't stack more than one toast at a time — queue them.
- Don't use toasts for field-level validation — use the field's error state.
- Don't auto-dismiss a toast that has an action — keep it visible until the action is taken or explicitly dismissed.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Two components for one primitive — consolidate with Toast - With Button. |
| C2 | Variant & Property Naming | Rework | theme overloaded with status; Large Label is a size flag; booleans on yes/no. |
| C3 | Token Coverage | Ready | All colors bound to main/toast/color/{mode}/*. Spacing + typography fully tokenized. |
| C4 | Native Mappability | Rework | No SwiftUI first-party primitive; Compose has Snackbar. Needs documented mapping + ToastManager pattern. |
| C5 | Interaction State Coverage | Rework | No auto-duration, swipe, or tap-to-dismiss contract; pending has no animation. |
| C6 | Asset & Icon Quality | Rework | Pending uses icon-placeholder gray circle instead of a real spinner. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on family consolidation and axis cleanup. |
Effective axes today: Type (3) × Theme (3, coupled to Type) × With Icon (2) × Large Label (2). Built variants collapse the illegal combinations — Error only pairs with theme=default; Pending only pairs with theme=dark | light + with icon=yes.=16 built variants.
| Group | Count | Axes |
|---|---|---|
| Default / Dark | 4 | withIcon=yes/no × largeLabel=yes/no |
| Default / Light | 4 | withIcon=yes/no × largeLabel=yes/no |
| Error / Default | 4 | withIcon=yes/no × largeLabel=yes/no |
| Pending / Dark | 2 | withIcon=yes (forced) × largeLabel=yes/no |
| Pending / Light | 2 | withIcon=yes (forced) × largeLabel=yes/no |
View full breakdown (16 rows)
| # | Node | Type | Theme | With Icon | Large Label | Dimensions |
|---|---|---|---|---|---|---|
| 1 | 27:53136 | default | dark | yes | yes | 312 × 38 |
| 2 | 3424:1308 | pending | dark | yes | yes | 312 × 38 |
| 3 | 27:53145 | default | light | yes | yes | 312 × 38 |
| 4 | 3424:1336 | pending | light | yes | yes | 312 × 38 |
| 5 | 27:53154 | error | default | yes | yes | 312 × 38 |
| 6 | 27:53163 | default | dark | no | yes | 312 × 38 |
| 7 | 27:53166 | default | light | no | yes | 312 × 38 |
| 8 | 27:53169 | error | default | no | yes | 312 × 38 |
| 9 | 27:53172 | default | dark | yes | no | 312 × 38 |
| 10 | 3424:1386 | pending | dark | yes | no | 312 × 38 |
| 11 | 27:53180 | default | light | yes | no | 312 × 38 |
| 12 | 3424:1392 | pending | light | yes | no | 312 × 38 |
| 13 | 27:53188 | error | default | yes | no | 312 × 38 |
| 14 | 27:53196 | default | dark | no | no | 312 × 38 |
| 15 | 27:53199 | default | light | no | no | 312 × 38 |
| 16 | 27:53202 | error | default | no | no | 312 × 38 |
theme axis, rename Large Label to size, and replace the Pending placeholder with a real spinner. Openaction slot. Opentheme mixes appearance + status; Large Label is a size flag; booleans on yes/no. Openicon-placeholder circle; adopt a real spinner instance. OpenA labeled toggle — the most common form of Toggle in settings and forms. Today this is a single layout frame (180 × 24) containing a Toggle instance and a "Label" text layer, with no properties, no variants, and no slots. Needs to become a proper component matching the shape already established by Radio Button With Label.
label, optional description, optional helper/error text, required marker, placement=leading | trailing, and inherit Toggle's state + size from the inner Toggle instance. Once built, drop-in for settings rows, form opt-ins, and list items.Labeled toggle is the primary form of Toggle shown in product. Settings rows, feature opt-ins, biometric/notification preferences — nearly all consumer-facing toggles are labeled.
This preview shows the proposed component shape — the current Figma is just a layout frame, so there are no real properties to toggle.
Click the toggle to flip it — mirrors Figma's Toggle variant swap (isActive=Yes ↔ No). Type in the label or description to see how the layout handles different copy lengths. Since Toggle-With-Label is a layout frame today (not a real component), every property here is proposed.
| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | Frame only | Today: one static instance. Proposed: Toggle + label rendered in row. |
| Pressed | Missing | Missing | Not built | Tapping label should also toggle — entire row is the tap target. |
| Disabled | Missing | Missing | Not built | Label dims to secondary when toggle is disabled. |
| Error | Missing | Missing | Not built | Required toggle + form submit shows error text below label. |
- Not a real component. Layout frame with a Toggle + text, no properties or variants. Promote to a proper component. C1 · Layer Structure & Naming
- No slots. Cannot set label, add description, mark required, or show helper/error. C2 · Variant & Property Naming
- No placement option. iOS Form convention is trailing toggle; Material 3 allows either. Need
placement=leading | trailing. C4 · Native Mappability - No state coverage. Pressed row, disabled label, error visual all missing. C5 · Interaction State Coverage
- No Code Connect mapping. Blocked until component exists. C7 · Code Connect Linkability
- Build as a real component with property set:
label,description?,helper?,error?,required: boolean,placement=leading | trailing. InheritisSelected,State,Sizefrom the inner Toggle via instance-swap. Composition - Match Radio Button With Label's shape for consistency — same label/description layout, same required marker, same error styling. Selection controls should read alike. Family
- Whole-row tap target. Tapping the label or the description should toggle the switch — the entire row is the hit region. Matches iOS Form and Material 3 list-item behavior. State
- Consider a List Row wrapper that adds full-width chrome (dividers, padding) for use inside Settings screens. Today this shape would be built ad-hoc per screen; a dedicated variant makes Settings screens trivially composable. Family
- See:Toggle for the base control, Radio Button With Label for the target shape. Family
18482:36538A 180×24 layout frame: Toggle instance on the left of its auto-layout, "Label" text on the right. No property set, no variants — functionally identical to placing a Toggle + Text next to each other on a canvas.
180 (fixed)24space/space-8Default arrangement: label stack on the left, toggle on the right. Matches iOS Form and Material 3 list-item patterns.
StringString?trailingfalseString?Body/M · 14/20 · text/primaryBody/S · 12/16 · text/secondaryBody/S · 12/16 · text/dangerInverse arrangement: toggle on the left, label stack on the right. Useful in inline form layouts where labels are right-heavy.
Mirrors Radio Button With Label's shape, with a placement prop for leading/trailing toggle arrangement (iOS Form defaults to trailing; Material 3 allows either).
EBToggleLabeled( label: "Push notifications", description: "Get alerts when money moves", isOn: $pushEnabled, placement: .trailing, required: false, error: nil )
EBToggleLabeled( label = "Push notifications", description = "Get alerts when money moves", checked = pushEnabled, onCheckedChange = { pushEnabled = it }, placement = EBTogglePlacement.Trailing, required = false, error = null )
.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBToggleLabeled
| Figma (proposed) | SwiftUI | Compose |
|---|---|---|
label: String | label: String | label: String |
description?: String | description: String? | description: String? |
isSelected: true | false | @Binding var isOn: Bool | checked: Boolean |
placement: leading | trailing | placement: EBTogglePlacement | placement: EBTogglePlacement |
required: boolean | required: Bool | required: Boolean |
helper?: String | helper: String? | helper: String? |
error?: String | error: String? | error: String? |
State (inherited) | .disabled(true) / .ebState(.error) | enabled / error |
ios/Components/Toggle/EBToggleLabeled.swiftandroid/components/toggle/EBToggleLabeled.kt
// Settings row EBToggleLabeled( label: "Push notifications", description: "Get alerts when money moves", isOn: $pushEnabled ) // Form opt-in with required marker EBToggleLabeled( label: "I accept the Terms of Service", isOn: $acceptedTerms, required: true, error: showError ? "You must accept to continue" : nil ) // Leading placement (inline form) EBToggleLabeled( label: "Remember me", isOn: $rememberMe, placement: .leading )
// Settings row EBToggleLabeled( label = "Push notifications", description = "Get alerts when money moves", checked = pushEnabled, onCheckedChange = { pushEnabled = it } ) // Form opt-in with required marker EBToggleLabeled( label = "I accept the Terms of Service", checked = acceptedTerms, onCheckedChange = { acceptedTerms = it }, required = true, error = if (showError) "You must accept to continue" else null ) // Leading placement EBToggleLabeled( label = "Remember me", checked = rememberMe, onCheckedChange = { rememberMe = it }, placement = EBTogglePlacement.Leading )
| Requirement | iOS | Android |
|---|---|---|
| Label ↔ Toggle link | VoiceOver announces label + state in one utterance ("Push notifications, on"). | TalkBack announces label + state in one utterance. Use Modifier.toggleable on the row. |
| Whole row tappable | Row wrapped in Button or .onTapGesture that toggles. | Row uses Modifier.toggleable, merging semantics. |
| Description announced | Combine label + description with .accessibilityElement(children: .combine). | Merge descendants, description as stateDescription or second line. |
| Required marker | Announce "required" after the label. | Append "required" to contentDescription. |
| Error | Error text linked via .accessibilityHint; announce on state change. | Error text in error semantics; TalkBack reads on focus. |
- Use for single-setting rows in Settings and forms.
- Write labels as statements: "Push notifications", "Biometric login" — not questions.
- Use description for one-line hints under the label.
- Mark
required: truefor terms/consent toggles that block submit.
- Don't build labeled toggles ad-hoc by placing Toggle + Text side by side — use this component.
- Don't write "Turn on notifications" as the label — just state the setting.
- Don't mix placement inside the same screen — pick leading or trailing per context and stick to it.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Not a component — just a layout frame. Promote to real component. |
| C2 | Variant & Property Naming | Rework | No properties. Add label, description, placement, required, helper, error. |
| C3 | Token Coverage | Ready | Label typography and color bound via Toggle + Text styles. |
| C4 | Native Mappability | Rework | Cannot map a frame. Once built, maps to Toggle inside LabeledContent on iOS, Row with Modifier.toggleable on Compose. |
| C5 | Interaction State Coverage | Rework | Need Default, Pressed (row), Disabled, Error. |
| C6 | Asset & Icon Quality | N/A | No assets. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until component exists. |
Today: 1 layout frame. Proposed: placement (leading/trailing) × description (yes/no) × required (yes/no) × state (5)=40 variants. May simplify by treating required and description as runtime props rather than Figma variants.
| # | Node | Dimensions | Contents |
|---|---|---|---|
| 1 | 18482:36538 | 180 × 24 | Toggle instance + "Label" text · auto-layout row |
isSelected, State, Size from the inner Toggle instance. OpenA binary on/off switch — the DS primitive for settings, feature flags, and opt-ins. 4 current variants from State=Default | Disabled × isActive=Yes | No. Size fixed at 48×24. Property schema is out of step with Checkbox and Radio Button — Toggle should be part of the shared "Selection Control" family.
isActive → isSelected, change Yes/No values to true/false, expand states from 2 → 5 (Default, Pressed, Focused, Disabled, Error), add Small/Medium/Large sizes. Once normalized, Toggle sits alongside Checkbox and Radio Button under one shared schema and maps cleanly to native Toggle / Switch.Toggle appears in settings rows, form opt-ins, and any control that flips a single boolean. Usually paired with a label (see Toggle - With Label).
Click the toggle to flip it — it mirrors the Figma component's State=Default × isActive=Yes/No variant swap. Properties below only expose what's built in Figma today. Pressed / Focused / Error are proposals (see Open Issues) and live in Figma once the normalization ships.
isActive + Yes/No breaks the DS convention set by Checkbox (isSelected + true/false). Selection controls should share one schema.| State | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Default · Off | Yes | Yes | State=Default, isActive=No | Gray track, knob left. |
| Default · On | Yes | Yes | State=Default, isActive=Yes | Brand track, knob right. |
| Pressed | Missing | Missing | Not built | Need darker track + scaled knob — critical feedback for tap. |
| Focused | Missing | Missing | Not built | 2px focus ring for keyboard / switch-control users. |
| Disabled · Off / On | Yes | Yes | State=Disabled | Muted track/knob, tap blocked. |
| Error | Missing | Missing | Not built | Needed when required toggle (e.g. "accept terms") is unset on submit. |
- Property schema diverges from Checkbox. Rename
isActive→isSelected, change valuesYes/No→true/false. Selection controls should share one schema. C2 · Variant & Property Naming - Missing interaction states. Only Default + Disabled built. Add Pressed, Focused, Error to match Checkbox's 5-state model. C5 · Interaction State Coverage
- Missing size axis. No Small/Medium/Large. Add to match Checkbox + Radio Button. C2 · Variant & Property Naming
- No Code Connect mapping. Blocked until schema normalizes. C7 · Code Connect Linkability
- Normalize to the Selection Control schema. Bring Toggle in line with Checkbox and Radio Button so all three share one property language:
isSelected: true | false(fromisActive: Yes | No) ×State: Default | Pressed | Focused | Disabled | Error(up from Default/Disabled only) ×Size: Small | Medium | Large(new axis). Variant count grows from 4 → 30, all covered by a clean 2 × 5 × 3 matrix. Property - Promote Toggle - With Label to a proper component with
label, optionaldescription, optionalhelper/errortext,requiredmarker, andplacement=leading | trailing. See Toggle - With Label. Composition - Consider a Loading state for async toggles (settings that sync to the server). Shows a spinner on the knob while the request is in flight — common in Material 3 and iOS 17. State
- Document the ARIA role. Natively, Toggle maps to the
switchrole witharia-checked=true | false. Screen readers say "on/off" instead of "checked/unchecked" — the correct affordance for a settings toggle. A11y
18482:36509The "off" resting state. Gray track, white knob pinned left.
DefaultNo| ROLE | TOKEN | VALUE |
|---|---|---|
| Track (off) | toggle/track/off | #C8CDD5 |
| Knob | toggle/knob | #FFFFFF |
| Knob shadow | shadow/elevation-1 | 0 1 2 rgba(10,23,87,0.08) |
48 × 2420 × 20212 (pill)18482:36512The "on" resting state. Brand-blue track, knob pinned right.
DefaultYes| ROLE | TOKEN | VALUE |
|---|---|---|
| Track (on) | toggle/track/on | #005CE5 |
| Knob | toggle/knob | #FFFFFF |
18482:36515Disabled off state. Muted gray track; interaction blocked.
DisabledNo| ROLE | TOKEN | VALUE |
|---|---|---|
| Track | toggle/track/disabled-off | #EBEEF3 |
| Knob | toggle/knob/disabled | #F4F6FA |
18482:36518Disabled on state. Muted brand-blue track; interaction blocked.
DisabledYes| ROLE | TOKEN | VALUE |
|---|---|---|
| Track | toggle/track/disabled-on | #9BC5FD |
| Knob | toggle/knob/disabled | #F4F6FA |
.package(url: "https://github.com/gcash/eb-design-system-ios", from: "1.0.0") import EBDesignSystem
implementation("com.gcash.designsystem:eb-components:1.0.0") import com.gcash.designsystem.components.EBToggle
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
isActive: Yes | No | isSelected: true | false | @Binding var isOn: Bool | checked: Boolean |
State: Default | Disabled | State: Default | Pressed | Focused | Disabled | Error | Modifier: .disabled(true), .ebState(.error) | enabled: Boolean, error: Boolean |
| (no size axis) | Size: Small | Medium | Large | .controlSize(.small / .regular / .large) | size: EBToggleSize |
ios/Components/Toggle/EBToggle.swiftandroid/components/toggle/EBToggle.kt
// Standalone toggle EBToggle(isOn: $notificationsEnabled) // Small size, in a compact row EBToggle(isOn: $reduceMotion) .controlSize(.small) // Disabled EBToggle(isOn: .constant(true)) .disabled(true) // Error state (required toggle on form submit) EBToggle(isOn: $acceptedTerms) .ebState(showError ? .error : .default)
// Standalone toggle EBToggle( checked = notificationsEnabled, onCheckedChange = { notificationsEnabled = it } ) // Small size EBToggle( checked = reduceMotion, onCheckedChange = { reduceMotion = it }, size = EBToggleSize.Small ) // Disabled EBToggle(checked = true, onCheckedChange = {}, enabled = false) // Error state EBToggle( checked = acceptedTerms, onCheckedChange = { acceptedTerms = it }, error = showError )
| Requirement | iOS | Android |
|---|---|---|
| Switch role | SwiftUI Toggle automatically applies the switch accessibility trait — VoiceOver says "on/off", not "checked/unchecked". | Material Switch applies Role.Switch semantics automatically. |
| Touch target | Minimum 44 × 44pt (pad the container — the 48×24 track alone is too small). | Minimum 48 × 48dp. |
| State announcement | VoiceOver announces "On" / "Off" as the value. | TalkBack announces "On" / "Off" as the state description. |
| Disabled | .disabled(true) blocks interaction; VoiceOver announces "dimmed". | enabled=false blocks click; TalkBack announces "disabled". |
| Focus (external keyboard / switch control) | iPad keyboards and Switch Control need a visible focus ring — must be added as part of the Focused state. | D-pad / keyboard focus indicator — must be added as part of the Focused state. |
- Use for single on/off settings where the change takes effect immediately.
- Pair with a label (Toggle - With Label) — a bare toggle needs context.
- Reflect the toggle's current value as the primary state — don't use it for "confirm later" actions.
- Keep the track 48×24 or larger; pad the hit target to ≥44×44pt / 48×48dp.
- Don't use Toggle for multi-selection — use Checkbox.
- Don't use Toggle for exclusive choice between options — use Radio Button or a Segmented Control.
- Don't require a "Save" button after a toggle — the toggle itself should commit the change.
- Don't use
Yes/Noas values in code — usetrue/false.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Track/knob layers cleanly named. |
| C2 | Variant & Property Naming | Rework | Rename isActive → isSelected, values Yes/No → true/false; add Size axis. |
| C3 | Token Coverage | Ready | Track + knob + shadow bound to tokens. |
| C4 | Native Mappability | Ready | Maps 1:1 to SwiftUI Toggle / Material Switch. |
| C5 | Interaction State Coverage | Rework | Missing Pressed, Focused, Error. Need full 5-state model. |
| C6 | Asset & Icon Quality | N/A | No icons. |
| C7 | Code Connect Linkability | Not Mapped | Blocked until schema normalizes. |
State × isActive=4 variants today. Proposed: isSelected × State × Size=30 variants.
| # | Node | State | isActive | Dimensions |
|---|---|---|---|---|
| 1 | 18482:36509 | Default | No | 48 × 24 |
| 2 | 18482:36512 | Default | Yes | 48 × 24 |
| 3 | 18482:36515 | Disabled | No | 48 × 24 |
| 4 | 18482:36518 | Disabled | Yes | 48 × 24 |
isActive → isSelected; change values Yes/No → true/false. OpenA dark-translucent directional tooltip intended for use over photographic or high-contrast content. Surface is #0A2757 at 80% opacity with a 2.5 px backdrop blur; contains a header and description (no icon, no close, no CTA); ships as 4 variants on a single pointer enum (top/right/bottom/left). This is the third Tooltip sibling in the DS — alongside Tooltip V2 and Onboarding - Tooltip — and is the weakest case for a distinct component: "blurred and transparent" is a visual treatment, not a role.
appearance: .translucent.background(.ultraThinMaterial) (or a tinted .regularMaterial); Compose does it with Modifier.blur() on an underlying layer (or a Haze library for true backdrop blur). Collapse this component into the unified Tooltip proposed on the V2 page as one of three appearance values (.default | .onboarding | .translucent). The translucent appearance binds to a main/nudge/color/secondary/* token set on the surface and swaps the label colors for the light-on-dark pair. No dedicated component; no "V2"-style duplicate.Three sibling components in the DS do roughly the same job. They differ by skin, not role. The family-level target architecture lives on the Tooltip V2 page; this entry documents where this specific sibling slots in.
| Component | Node | Variants | What's different | Proposed role |
|---|---|---|---|---|
| Tooltip V2 | 70:14908 | 8 | Opaque white surface. Header/Description/Icon/CTA presence axes. | appearance: .default |
| Onboarding - Tooltip | 51:17066 | — | Walkthrough / coach-mark flavor — richer content, step indicator, prev/next CTAs. | appearance: .onboarding |
| Tooltip Blurred and Transparent (this) | 49:335349 | 4 | Dark translucent surface (#0A2757 @ 80%) + 2.5 px backdrop blur. Header + description only. | appearance: .translucent |
Of the three siblings, this one has the weakest case for standalone existence — its schema is a strict subset of V2's (header + description only, no icon/close/CTA), and its visual differentiation reduces to two tokens (secondary surface / secondary label) plus a material effect that lives on the platform, not in Figma.
Used over photographic, gradient, or high-contrast imagery where an opaque white tooltip would feel heavy. The backdrop blur keeps the background legible while the dark surface carries white type with full contrast.
Toggle header presence and placement. This sibling has no CTA, icon, or close control — if you need those, use Tooltip V2. Under the unified target schema, all three flavors share one component and these toggles become appearance + placement + content flags.
main/nudge/color/secondary/* tokens; spacing to space/*; radius to radius/radius-2. Blur amount (2.5 px) is a raw literal — no effect/blur-* token. Pointer is a raster. C6pointer enum here — which makes the 4-boolean shape on Tooltip V2 even harder to defend. C2| Behavior | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Show / hide | Yes | Yes | Not annotated | Expected: fade + slight scale-in anchored on the pointer side. Under the unified schema, shared with other appearance values. |
| Backdrop blur | Material | Modifier | backdrop-blur 2.5 px | iOS: .background(.ultraThinMaterial) or a custom blurred UIVisualEffectView. Compose: Modifier.blur() or a Haze effect for true behind-content blur. |
| Tap outside | Implicit | Implicit | Not defined | Standard tooltip contract — tap-outside dismisses. Same as other Tooltip siblings. |
| Pressed / Focused | Missing | Missing | Not built | No interaction states modelled. No dismiss affordance at all — consumer must rely on tap-outside or a timer. |
| Reduce transparency | Required | Required | Not defined | iOS: respect UIAccessibility.isReduceTransparencyEnabled — fall back to an opaque #0A2757 surface. Android: same fallback when high-contrast mode is on. |
- Visual treatment shipped as a standalone component. "Blurred and Transparent" describes a surface material + opacity, not a component role. The reusable unit is an
appearanceflag on the canonical Tooltip, not a separate Figma component. iOS and Compose both model this as a modifier applied to an existing view — never as a distinct type. C1 · Layer Structure & Naming - Third sibling Tooltip for one primitive. Alongside
Tooltip V2(70:14908) andOnboarding - Tooltip(51:17066), this duplicates the tooltip primitive. Fold all three into one component withappearance: .default | .onboarding | .translucent. C1 · Layer Structure & Naming - Component name describes a visual effect, not a role. "Tooltip Blurred and Transparent" chains two adjectives instead of naming what the component does. Under the unified schema, this becomes
Tooltip / appearance=.translucent— the role stays "Tooltip", and the treatment is a property value. C2 · Variant & Property Naming - Backdrop blur is a platform material, not a Figma shape. The 2.5 px blur is modelled as a Figma layer effect, but native platforms provide this via
.ultraThinMaterial(SwiftUI),UIVisualEffectView(UIKit), andModifier.blur()/ Haze libraries (Compose). A 1:1 Code Connect mapping of today's shape would emit a drawn blur surface instead of calling the correct platform API. C4 · Native Mappability - No icon, close, or CTA support. Schema is a strict subset of Tooltip V2 (header + description only). Consumers needing a leading icon, dismiss X, or a CTA must abandon this component and switch to V2 — losing the translucent surface in the process. An
appearanceproperty on one unified component removes this forced trade-off. C4 · Native Mappability - Pointer triangle is a raster asset. 4 separate image fills (
imgPointer,imgPointer1,imgPointer2,imgPointer3) — one per edge — for what should be a single vector shape rotated by thepointerenum. Same anti-pattern as Tooltip V2. C6 · Asset & Icon Quality - No interaction states. No Pressed / Focused / Dismissing states modelled. There is no dismiss control at all — no close X, no tap-to-dismiss contract in the component, no lifecycle annotation. C5 · Interaction State Coverage
- Blur amount (2.5 px) is a raw literal. Not bound to an
effect/blur-*token. If the DS later introduces a translucent-surface token set, this component won't pick up the change automatically. C6 · Asset & Icon Quality - Code Connect mappings not registered. Blocked on the consolidation — mapping today's shape would cement a standalone "translucent tooltip" type that shouldn't exist. Map once as one of the
appearancevalues of the unified Tooltip. C7 · Code Connect Linkability
- Fold this component into the unified Tooltip as
appearance: .translucent. Deprecate the standalone "Tooltip Blurred and Transparent" node. The translucent appearance flips surface and label tokens (main/nudge/color/secondary/bg+main/nudge/color/secondary/label) and, on native, applies.background(.ultraThinMaterial)/Modifier.blur(). No new component; no schema duplication. Family - Drop the visual-effect name. Rename to — or merge away as —
Tooltipwithappearance=.translucent. Component names should describe roles, not treatments. Rename - Bind the translucent surface to a named token set. Today the surface is
main/nudge/color/secondary/bg(#0A2757raw) paired with an 80% group opacity. Move to a propermain/nudge/color/translucent/{bg,label,description}token set with the alpha baked in, so the.translucentappearance picks up theme updates automatically. Token - Tokenize the blur radius. Replace the raw
2.5px literal with aneffect/blur-tooltip(or similar) token. Any future translucent surface (Modal, Overlay, Sheet) can then reuse the same value. Token - Replace the raster pointer with a vector triangle shared across appearances. One vector shape, rotated per
placement, picks up the appearance's surface token automatically. Fixes the raster problem across all 3 siblings at once. Asset - Instance-swap (conceptually) to the canonical Tooltip. Consumers currently using this component should swap to
Tooltip / appearance=.translucentduring the migration; leave a short-lived alias variant if needed to avoid breaking existing instances. Composition - Document the reduce-transparency fallback. When iOS
isReduceTransparencyEnabledor Android high-contrast is on, fall back to a solid#0A2757surface (no blur). Write this on the unified Tooltip's accessibility section so all consumers see it. A11y - Document
.translucentas photography-only. Annotate on the unified Tooltip: "Use.translucentonly over photographic or gradient backgrounds; for flat UI backgrounds, prefer.default." Gives designers a clear usage rule. Docs
4 variants on a single pointer enum — top, right, bottom, left. Header + description are always present in today's shipped variants (the underlying code exposes a showHeader prop, but it isn't wired to a Figma variant property). Under the unified Tooltip, these 4 collapse into the shared placement enum.
Pointer above the surface — anchors a target element below. 336 × 89.
Pointer on the right — anchors a target element to the right of the surface. 348 × 77.
Pointer below the surface — anchors a target element above. 336 × 89.
Pointer on the left — anchors a target element to the left of the surface. 348 × 77.
| Role | Token | Default |
|---|---|---|
| Surface | main/nudge/color/secondary/bg | #0A2757 @ 80% group opacity |
| Backdrop blur | — (untokenized literal) | 2.5 px |
| Header label | main/nudge/color/secondary/label | #FFFFFF |
| Description | main/nudge/color/secondary/description | #F6F9FD @ 80% (#F6F9FDCC) |
| Pointer triangle | — (raster, 4 images) | #0A2757 (baked into raster) |
No Pressed / Disabled states modelled at the tooltip level. No close / CTA — those roles are absent from this sibling. Blur amount should move to a token (effect/blur-tooltip) alongside the translucent surface tokens.
| Property | Token | Value |
|---|---|---|
| Surface width — top / bottom | — | 336 px |
| Surface width — left / right | — | 336 px (content) · 348 px (with pointer offset) |
| Surface corner radius | radius/radius-2 | 6 px |
| Surface padding | space/space-16 | 16 px all sides |
| Surface background alpha | — | 0.80 (group opacity) |
| Backdrop blur | — | 2.5 px (untokenized) |
| Header → description gap | space/space-4 | 4 px |
| Pointer width / height | — | 22 × 12 (top/bottom) · 20 × 12 (left/right) — raster |
| Pointer → surface gap | space/space-0 | 0 px (abutting) |
No internal CTA / icon / close dimensions — those roles are absent from this sibling. Unified with Tooltip's full schema, the translucent appearance would inherit the same layout tokens as default/onboarding.
| Element | DS text style | Spec |
|---|---|---|
| Header | Primary/Headlines/Block | Proxima Soft Bold · 18 / 23 · +0.25 |
| Description | Secondary/Bold/Caption | BarkAda Semibold · 12 / 18 · +0 |
Type styles identical to Tooltip V2 — only the color tokens change (white-on-dark vs dark-on-white). Another signal that "translucent" is an appearance, not a component. Custom-font standing action item applies (BarkAda).
This sibling shares a package with the unified Tooltip. No separate component module.
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:tooltip:1.0.0") }
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
| Tooltip Blurred and Transparent | Tooltip (same component) | EBTooltip | EBTooltip |
| (distinct sibling) | appearance: .translucent | .ebAppearance(.translucent) | appearance=EBTooltipAppearance.Translucent |
| pointer: top / right / bottom / left | placement: .top / .right / .bottom / .left | arrowEdge: Edge | anchorPosition: EBTooltipAnchor |
| header (baked text) | title: String? | title: String? | title: String?=null |
| description (baked text) | body: String? | body: String? | body: String?=null |
| backdrop-blur 2.5 px (raw) | (absorbed into .translucent appearance) | .background(.ultraThinMaterial) | Modifier.blur(radius=2.5.dp) |
| surface @ 80% opacity | main/nudge/color/translucent/bg | Color.nudgeTranslucentBg | EBColors.nudgeTranslucentBg |
| (not modelled — no close) | hasDismiss: Bool (inherited) | dismissible: Bool | dismissible: Boolean=true |
// Translucent tooltip over imagery EBTooltip( title: "Featured deal", body: "Limited-time discount on your next top-up.", placement: .top ) .ebAppearance(.translucent) // Under the hood, .translucent applies: // .background(.ultraThinMaterial) // .foregroundStyle(.white) // + respects UIAccessibility.isReduceTransparencyEnabled
// Translucent tooltip over imagery EBTooltip( title = "Featured deal", body = "Limited-time discount on your next top-up.", placement = EBTooltipPlacement.Top, appearance = EBTooltipAppearance.Translucent ) // Under the hood, .Translucent applies: // Modifier.blur(radius = 2.5.dp) on the backing layer // background = nudgeTranslucentBg (#0A2757 @ 80%) // + respects high-contrast settings (falls back to solid #0A2757)
| Requirement | iOS | Android |
|---|---|---|
| Reduce transparency | Respect UIAccessibility.isReduceTransparencyEnabled — fall back to an opaque #0A2757 surface with no blur. | Respect high-contrast text setting — fall back to an opaque #0A2757 surface with no blur. |
| Contrast | White-on-#0A2757 meets WCAG AA at both states. Over photographic backdrops, the 80% alpha + blur keeps contrast above 4.5:1 in the designer's tested scenes. | Same. Always test the .translucent appearance over real content; do not use it for critical error messaging. |
| Reduce motion | Respect UIAccessibility.isReduceMotionEnabled — fade only; skip scale-in. | Respect Settings.Global.TRANSITION_ANIMATION_SCALE — fade only when motion is reduced. |
| Role | Announce as tooltip; group title + body via .accessibilityElement(children: .combine). | semantics { role=Role.Popup; mergeDescendants=true }. |
- Use the translucent appearance only over photographic, gradient, or richly-colored backgrounds where an opaque tooltip would feel heavy.
- Verify contrast against the actual content behind the tooltip — a busy background can still reduce legibility even with 80% alpha + blur.
- Fall back to the default (opaque) appearance when reduce-transparency or high-contrast is active.
- Prefer the unified Tooltip's
appearanceproperty; don't branch on component type at the call site.
- Don't use the translucent appearance over flat UI backgrounds — use
appearance: .default. - Don't use for error or status messaging — use Alert.
- Don't ship a standalone "translucent tooltip" component — this sibling should be deprecated in favor of one
appearancevalue on the unified Tooltip. - Don't hardcode the blur radius in product code — bind to the
effect/blur-tooltip(or equivalent) token.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Third sibling for one primitive. A visual treatment shipped as a discrete component. Consolidate into the canonical Tooltip. |
| C2 | Variant & Property Naming | Rework | Component name is a visual-effect description ("Blurred and Transparent"). Should collapse into Tooltip / appearance=.translucent. |
| C3 | Token Coverage | Refine | Surface, label, description, radius, and spacing all bound to tokens. The blur amount (2.5 px) and the 80% group opacity are raw literals — move to a translucent-surface token set. |
| C4 | Native Mappability | Rework | Backdrop-blur is a platform material (.ultraThinMaterial / Modifier.blur()), not a component shape. A 1:1 Figma→native mapping today would emit a drawn blur layer instead of calling the correct API. Fixes when consolidated as an appearance value. |
| C5 | Interaction State Coverage | Rework | No Pressed / Focused / Dismissing states. No dismiss affordance at all (no close X). |
| C6 | Asset & Icon Quality | Rework | Pointer triangle is 4 raster images (one per edge). Same anti-pattern as Tooltip V2. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on consolidation — mapping today's shape would cement a duplicate component. Map once as one of the unified Tooltip's appearance values. |
One enum axis yields 4 variants — pointer: top / right / bottom / left. Header + description are present in all shipped variants. Under the unified Tooltip, these 4 collapse into the shared placement enum, and the distinguishing surface treatment moves to appearance=.translucent.
| # | Pointer | Dimensions | Node |
|---|---|---|---|
| 1 | top | 336 × 89 | 49:335345 |
| 2 | right | 348 × 77 | 49:335347 |
| 3 | bottom | 336 × 89 | 49:335348 |
| 4 | left | 348 × 77 | 49:335346 |
appearance: .translucent. Do not ship a separate "Tooltip Blurred and Transparent" component. Openappearance enum. OpenTooltip / appearance=.translucent. Open.ultraThinMaterial; Compose uses Modifier.blur(). A component can't model a native modifier 1:1. OpenTooltip V2's 4-boolean pointer shape, this sibling already uses a single pointer enum — the correct model. Notedmain/nudge/color/secondary/*. Spacing via space/*. Radius via radius/radius-2. NotedA directional floating popover — used for tips, pointers-to-action, and short in-product explanations. Contains a header, description, close X, optional leading icon, optional 1–2 CTAs, and a pointer triangle on any of four sides. Ships as 8 variants across 4 axes (CTA, Icon, Description, Header) — and is one of three Tooltip siblings in the DS (Tooltip V2, Onboarding - Tooltip, Tooltip Blurred and Transparent). The version suffix in the name is the first red flag.
Tooltip with placement: .top | .right | .bottom | .left (one enum, not 4 booleans), appearance: .default | .onboarding | .translucent, hasArrow, hasDismiss, and a content slot. Replace the raster pointer with a vector, the placeholder icon circle with a Figma Slot, and strip the V2 suffix — production component names should never carry a version. Maps cleanly to TipKit / PlainTooltip / RichTooltip on native.Three sibling components in the DS do roughly the same job. They differ by skin, not role.
| Component | Node | Variants | What's different | Proposed role |
|---|---|---|---|---|
| Tooltip V2 (this) | 70:14908 | 8 | Header/Description/Icon/CTA presence axes. Opaque white surface. | appearance: .default |
| Onboarding - Tooltip | 51:17066 | — | Walkthrough / coach-mark flavor — typically richer content, step indicator, prev/next CTAs. | appearance: .onboarding |
| Tooltip Blurred and Transparent | 49:335349 | — | Translucent surface with backdrop blur — used over photographic / high-contrast content. | appearance: .translucent |
Target architecture: one Tooltip with an appearance enum, one placement enum replacing the 4 pointer booleans, optional hasArrow / hasDismiss, and a single content slot that accepts either a simple string or a rich sub-tree.
Tooltips sit over a target element (tab, button, icon, card) with a pointer aimed at the thing they describe.
Toggle content slots, placement, and the CTA axis. Note that pointer direction in today's Figma is 4 independent booleans — the demo below uses the correct single enum shape.
main/nudge/* + space/* tokens. But the pointer arrow is a raster image (4 rotated copies of one shape) and the leading icon is a gray placeholder circle, not a slot. C6Tooltip V2 — version suffixes don't belong in production DS names. Pointer direction is 4 separate booleans rather than one enum, letting consumers set nonsensical combinations (e.g. all 4 pointers on). C2| Behavior | iOS | Android | Figma Spec | Notes |
|---|---|---|---|---|
| Show / hide | Yes | Yes | Not annotated | Expected: fade + slight scale-in anchored on the pointer side. |
| Tap close (X) | Yes | Yes | Close layer | Dismiss icon is present in markup but not wired to a property or interaction. Contract should be explicit. |
| Tap outside | Implicit | Implicit | Not defined | Standard tooltip contract — tap-outside dismisses. Should be documented on the component. |
| Primary CTA | Yes | Yes | CTA=one / two | Advances in onboarding or performs the tip's primary action. |
| Secondary CTA | Yes | Yes | CTA=two | "Back" in onboarding; "Learn more" in tips. |
| Pressed / Focused | Missing | Missing | Not built | CTAs and close have no pressed/focused treatment on the tooltip component itself. Inner Button instances handle their own press, but close + the tooltip surface do not. |
- Component is named
Tooltip V2. Version suffixes don't belong in production DS component names — they imply a V1 that wasn't removed, and force consumers to choose which version is "right". Rename toTooltipand delete V1 (or, if V1 is still in use, merge/deprecate before renaming). C2 · Variant & Property Naming - Three sibling Tooltip components for one primitive.
Tooltip V2(70:14908),Onboarding - Tooltip(51:17066), andTooltip Blurred and Transparent(49:335349) all model the same floating popover with different skins. Collapse into oneTooltipwith anappearanceenum. C1 · Layer Structure & Naming - Pointer direction is 4 independent booleans.
pointerTop,pointerRight,pointerBottom,pointerLeft— nothing prevents a consumer from enabling two or all four. A singleplacement: .top | .right | .bottom | .left | .noneenum is the correct shape. Maps 1:1 toTipKit.arrowEdgeand ComposeTooltipAnchorPosition. C2 · Variant & Property Naming - Pointer triangle is a raster asset. 4 separate image fills (
imgPointer,imgPointer1,imgPointer2,imgPointer3) for what should be one vector shape rotated per edge. Replace with a single vector triangle component; rotation handled by theplacementenum. C6 · Asset & Icon Quality - Leading icon is a gray placeholder circle. The
Icon=yesvariant renders a flat#C2C6CF46 px circle under a "Placeholder" frame — same anti-pattern as Action List / List Item. Replace with a namedleadingFigma Slot so consumers can drop in an Icon, Avatar, or illustration. C6 · Asset & Icon Quality - Close (X) is an image asset, not a DS icon instance. The dismiss control uses
imgShapeFullinside a generic "Close" frame rather than an instance of the DS'sicon/close. That hides the icon from a11y / token updates and blocks Code Connect from seeing it as a real control. C1 · Layer Structure & Naming - CTA Button padding drifts between variants.
CTA=one, Header=yesusespx-16 / py-12;CTA=one, Header=nousespx-12 / py-6;CTA=twouses yet another combo. The underlyingButton - XSmallinstance should be identical across all variants — same size mode, same padding. C4 · Native Mappability - No dismiss / show states modeled. Close button exists visually but carries no interaction property; there is no Pressed / Focused state on the dismiss control or on CTAs at the tooltip level. Tooltips also have an implicit "appearing / dismissing" lifecycle that isn't documented. C5 · Interaction State Coverage
- Code Connect mappings not registered. Blocked on the consolidation + slot adoption + enum conversion. Mapping the current 3-sibling / 4-boolean shape to native would cement the wrong schema. C7 · Code Connect Linkability
- Consolidate the 3 Tooltip siblings into one component. New schema:
placement: .top | .right | .bottom | .left(replaces the 4 booleans),appearance: .default | .onboarding | .translucent(replaces the 3 sibling components),hasArrow: Bool,hasDismiss: Bool,cta: .none | .primary | .primaryAndSecondary, plus aleadingslot for icon/avatar and a content slot for the body. Replaces today's8 + ? + ?variants across 3 components with roughly4 placement × 3 appearance × 3 cta=36permutations of one clean schema. Family - Rename
Tooltip V2→Tooltip. Version suffixes shouldn't appear in production DS names. If V1 is still referenced anywhere, migrate its instances first, then delete V1, then drop the suffix. Rename - Replace 4 pointer booleans with a single
placementenum. Prevents nonsensical states (all 4 pointers on), maps 1:1 to SwiftUI.arrowEdgeand ComposeTooltipAnchorPosition, and reduces the variant matrix dramatically. Property - Replace the raster pointer with a vector triangle. Today there are 4 separate rasters (top/right/bottom/left). One vector shape, rotated per
placement, fills the same role with zero asset burden, scales cleanly, and picks up token color updates automatically. Asset - Adopt a
leadingFigma Slot for the icon. Drop the#C2C6CFplaceholder circle. Maps to@ViewBuilder(SwiftUI) and a@Composableslot (Compose). Empty slot=no leading. Slot - Instance-swap the close button to
icon/close. Use the canonical DS close-icon instance rather than an inlineimgShapeFull. Gets you token-driven color, a11y labeling, and press-state handling for free. Composition - Normalize CTA Button padding. Every tooltip variant uses the same Button size mode (XSmall). Enforce one padding via the Button component itself — don't let the tooltip override. Composition
- Document the dismiss contract + lifecycle. Add a description on the component: "Tap the close X or tap outside to dismiss, unless
hasDismiss=false(force interaction with CTA). Tooltip appears with fade + slight scale from the pointer anchor; dismisses with reverse." Close the gap between designer intent and dev implementation. Docs - Add Pressed / Focused on the close control and CTAs. Inner Button instances handle their own states, but the close X does not — it's an image. Once it becomes an icon-button instance, states follow. State
8 variants across 4 axes — CTA (none/one/two) × Icon (yes/no) × Description (yes/no) × Header (yes/no). Only 8 of the 24 theoretical combinations ship; the rest aren't exposed.
Full-shape onboarding variant. Leading icon placeholder + header + description + primary CTA + close. 359 × 181.
Text-only tip with a primary CTA. No leading icon. 359 × 155. Inherits different outer padding (px-16 py-12) than the hero variant (p-16).
Dismissible explanatory tooltip with an icon. 359 × 137.
Plain text tip with header + description + close. 359 × 119.
Single-line title pointer — used to label or point at a UI element. 359 × 79.
Description-only tooltip — short explanatory body, no title. 359 × 92.
Two-CTA walkthrough step: outline "Back" + filled "Next". 359 × 136.
Body + single CTA, no header. 359 × 136. Uses px-12 / py-6 CTA padding — different from the two variants above.
| Role | Token | Default |
|---|---|---|
| Surface | main/nudge/color/primary/bg | #FFFFFF |
| Border | main/nudge/color/primary/border | #E5EBF4 |
| Header label | main/nudge/color/primary/label | #0A2757 |
| Description | main/nudge/color/primary/description | #6780A9 |
| Close icon | main/nudge/color/primary/icon-close | #0A2757 |
| Leading icon placeholder | — | #C2C6CF (not tokenized — placeholder) |
| CTA primary bg | main/button/primary/brand/enabled/bg | #005CE5 |
| CTA primary label | main/button/primary/brand/enabled/label | #FFFFFF |
| CTA secondary border / label | main/button/secondary/brand/enabled/border | #005CE5 |
| Pointer triangle | — | raster (4 images) |
No Pressed / Disabled states modeled at the tooltip level. Pointer triangle should inherit the surface + border tokens once it becomes a vector.
| Property | Token | Value |
|---|---|---|
| Surface width | — | 335 px (content) · 359 px (with offsets) |
| Surface corner radius | radius/radius-2 | 6 px |
| Surface border | main/nudge/color/primary/border | 1 px solid |
| Surface padding — hero | space/space-16 | 16 px all sides |
| Surface padding — text+CTA | space/space-16 · space/space-12 | 16 / 12 (inconsistent with hero) |
| Leading icon size | — | 46 × 46 (placeholder circle) |
| Close size | — | 18 × 18 (image asset) |
| Pointer width / height | — | 24 × 12 (raster image) |
| Icon → text gap | space/space-4 | 4 px |
| Text-container gap | space/space-8 | 8 px |
| CTA row gap (two CTAs) | space/space-8 | 8 px (justify between) |
| CTA Button — size | — | XSmall (Button component) |
Padding drifts between variants — hero uses p-16, CTA=one+Header uses px-16 / py-12. Should be one rule.
| Element | DS text style | Spec |
|---|---|---|
| Header | Primary/Headlines/Block | Proxima Soft Bold · 18 / 23 · +0.25 |
| Description | Secondary/Bold/Caption | BarkAda Semibold · 12 / 18 · +0 |
| CTA label | Primary/Label/Base | Proxima Soft Bold · 16 / 16 · +0.25 |
BarkAda (secondary) is used only for the description. Custom-font standing action item applies.
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:tooltip:1.0.0") }
| Figma (today) | Figma (proposed) | SwiftUI | Compose |
|---|---|---|---|
| 3 sibling components | 1 component: Tooltip | EBTooltip | EBTooltip |
| (sibling=appearance) | appearance: .default / .onboarding / .translucent | .ebAppearance(.default / .onboarding / .translucent) | appearance: EBTooltipAppearance |
| pointerTop/Right/Bottom/Left: Bool × 4 | placement: .top / .right / .bottom / .left / .none | arrowEdge: Edge | anchorPosition: EBTooltipAnchor |
| header: Bool + text baked | title: String? | title: String? | title: String?=null |
| description: Bool + text baked | body: String? (or content slot) | body: String? | body: String?=null |
| icon: Bool (gray placeholder) | leading (Slot) | @ViewBuilder leading | leading: @Composable () -> Unit |
| Close image asset (always) | hasDismiss: Bool | dismissible: Bool | dismissible: Boolean=true |
| cta: none / one / two | cta: .none / .primary(String) / .pair(back, next) | primary / secondary: TooltipAction? | primaryAction / secondaryAction: TooltipAction? |
| outlineButton: Bool | (absorbed into cta.pair) | — | — |
| (not modeled) | hasArrow: Bool | arrow: Bool | showArrow: Boolean=true |
| (not modeled) | onDismiss | onDismiss: () -> Void | onDismiss: () -> Unit |
// Simple tip — title + body, pointer below EBTooltip( title: "Quick transfers", body: "Tap here to send money to recent contacts.", placement: .bottom ) .onDismiss { showTip = false } // Walkthrough step — two CTAs EBTooltip( title: "Step 2 of 4", body: "Review your balance before confirming.", placement: .top, cta: .pair(back: "Back", next: "Next") ) .ebAppearance(.onboarding) // Rich — custom leading slot EBTooltip(title: "Welcome", body: "Tap a card to begin.") { Image(systemName: "sparkles") } // iOS 17 + Tips — recommended for onboarding flows .popoverTip(quickTransferTip)
// Simple tip EBTooltip( title = "Quick transfers", body = "Tap here to send money to recent contacts.", placement = EBTooltipPlacement.Bottom, onDismiss = { showTip = false } ) // Walkthrough step — two CTAs, onboarding skin EBTooltip( title = "Step 2 of 4", body = "Review your balance before confirming.", placement = EBTooltipPlacement.Top, appearance = EBTooltipAppearance.Onboarding, primaryAction = TooltipAction("Next", onClick = { advance() }), secondaryAction = TooltipAction("Back", onClick = { rewind() }) ) // Custom leading slot EBTooltip( title = "Welcome", body = "Tap a card to begin.", leading = { Icon(Icons.Default.AutoAwesome, contentDescription = null) } ) // Material 3 PlainTooltip equivalent PlainTooltip { Text("Shortcut") }
| Requirement | iOS | Android |
|---|---|---|
| Role + focus | Announce tooltip as .accessibilityAddTraits(.isModal) when hasDismiss=true — otherwise role .staticText. | semantics { role=Role.Popup } on the container; TalkBack focuses it on appear. |
| Close control | Wrap close as a Button with accessibilityLabel "Dismiss tip" and 44×44 hit target. | IconButton with contentDescription="Dismiss tip"; 48×48dp minimum. |
| Dismiss-outside | Respect UIAccessibility.isVoiceOverRunning — do not auto-dismiss tooltips while VO is active. | Do not auto-dismiss while TalkBack is active; hold the tooltip until the user explicitly moves on. |
| Reduce motion | Respect UIAccessibility.isReduceMotionEnabled — skip the scale-in animation; fade only. | Respect Settings.Global.TRANSITION_ANIMATION_SCALE — fade only when user has motion reduced. |
| Combined label | Read title + body + "Dismiss" as one phrase; avoid reading pointer. | Same; set mergeDescendants=true on the container. |
- Use for short, transient explanations or pointer-to-action nudges tied to a specific element.
- Anchor the pointer at the target — consistency helps users associate the tip with its referent.
- Prefer one primary CTA; use two only for onboarding walkthroughs (Back + Next).
- Provide a dismiss control unless the tooltip is a required onboarding gate.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | 3 sibling components for one primitive. Close uses a raw image asset inside a generic frame rather than an icon/close instance. |
| C2 | Variant & Property Naming | Rework | Version suffix in the component name (Tooltip V2). Pointer direction is 4 booleans instead of one enum. |
| C3 | Token Coverage | Ready | Surface, border, label, description, and CTA colors bound to main/nudge/* and main/button/* tokens. Spacing via space/*. |
| C4 | Native Mappability | Rework | Maps cleanly once pointer booleans → placement enum and sibling skins → appearance enum. CTA padding inconsistencies block a 1:1 Button reuse. |
| C5 | Interaction State Coverage | Rework | No Pressed / Focused on close. No lifecycle (appearing / dismissing) annotated. Close isn't wired to a dismiss property. |
| C6 | Asset & Icon Quality | Rework | Pointer is 4 raster images (one per edge). Leading icon is a gray placeholder circle. Close is an image asset. |
| C7 | Code Connect Linkability | Not Mapped | Blocked on consolidation + enum conversion + slot adoption. Mapping today's shape would cement the wrong schema. |
4 booleans and one 3-value enum yield 8 shipped variants out of a 24-cell theoretical matrix (CTA (3) × Icon (2) × Description (2) × Header (2)=24). Only the 8 meaningful combinations are exposed. Pointer direction is 4 additional booleans outside the variant matrix.
| # | CTA | Icon | Description | Header | Dimensions | Node |
|---|---|---|---|---|---|---|
| 1 | one | yes | yes | yes | 359 × 181 | 70:14907 |
| 2 | one | no | yes | yes | 359 × 155 | 7977:12260 |
| 3 | none | yes | yes | yes | 359 × 137 | 70:14903 |
| 4 | none | no | yes | yes | 359 × 119 | 70:14902 |
| 5 | none | no | no | yes | 359 × 79 | 70:14900 |
| 6 | none | no | yes | no | 359 × 92 | 70:14901 |
| 7 | two | no | yes | no | 359 × 136 | 70:14905 |
| 8 | one | no | yes | no | 359 × 136 | 70:14906 |
V2 suffix; replace 4 pointer booleans with one placement enum; replace raster pointer with vector; replace placeholder icon with a Figma Slot. Openappearance enum. OpenTooltip V2 shouldn't exist in production; no V1 surfaces in the file. OpenpointerTop/Right/Bottom/Left with a single placement enum. Openmain/nudge/* and main/button/*. Spacing via space/*. NotedFile upload input with five states (Default, Uploading, Uploaded, Upload error, Uploaded with thumbnail) and an optional label above. 304px fixed width. Uses a Lottie animation for the progress bar during upload. 10 variants total across the state × hasLabel matrix.
boder → border token typo (library-wide). Normalize hasLabel to true/false. Split state="Uploaded with thumbnail" into state=uploaded + hasThumbnail: Bool. Rename "Upload error" → error (remove space). Adopt a Figma Slot for the thumbnail image. Add disabled state.Contexts are illustrative. Final screens will reference actual GCash patterns. Upload File appears in forms requiring document proof (KYC, insurance claims, verification).
Toggle state and hasLabel to see each variant render.
hasLabel uses yes/no, state="Upload error" contains a space, and "Uploaded with thumbnail" is really an orthogonal boolean, not a state. Also the boder typo in every border token. C2C3icon-placeholder pattern we've flagged in Chip, Tab Item, List Item Asset) — should be a Figma Slot so product teams can drop in any preview image. C6| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | state=Default | Empty input with paperclip + "Attach file / photo" label |
| Uploading | Yes | Yes | state=Uploading | Shows file name + Lottie progress bar + percentage |
| Uploaded | Yes | Yes | state=Uploaded | File name + trailing trash icon to remove |
| Uploaded with thumbnail | Yes | Yes | state=Uploaded with thumbnail | 52×52 image preview + truncated file name + trash. Should be orthogonal hasThumbnail prop. |
| Upload error | Yes | Yes | state=Upload error | Red 2px border + red subtext ("Maximum file size: 20MB") |
| Disabled / Pressed / Focused | N/A | N/A | — | Not defined. Engineers must improvise. C5 |
- Property naming issues.
hasLabelusesyes/noinstead oftrue/false;state="Upload error"contains a space;state="Uploaded with thumbnail"mixes two orthogonal dimensions (state + thumbnail presence). C2 · Variant & Property Naming - Token typo —
boder. Every border token is misspelled:main/upload-file/color/default/boder,main/upload-file/color/error/boder. Rename across the whole collection. C3 · Token Coverage - Thumbnail color hardcoded. Uses
#0057E4withopacity: 5%baked in instead of a token. C3 · Token Coverage - No disabled / pressed / focused states. Forms need a disabled variant for read-only views; pressed and focused are expected interaction affordances. C5 · Interaction State Coverage
- Thumbnail is a placeholder. 52×52 gray block instead of a swappable image slot. Should be a Figma Slot that accepts a real image instance. C6 · Asset & Icon Quality
- Lottie dependency. Progress bar requires the Lottie animation (
0a1cb540-b53a-4e28-afa5-8aa5ca7ebaa1) to be bundled with the native package. Document as a required asset. C6 · Asset & Icon Quality - Code Connect mappings not registered. Blocked until state/property restructure and token rename land. C7 · Code Connect Linkability
- Restructure the state property:
•state: default / uploading / uploaded / error(4 values, clean enums)
•hasThumbnail: Bool(orthogonal — can combine withuploaded)
•hasLabel: Bool(true/false)
•disabled: Bool(new)
Collapses 10 variants into 4 states × 2 hasLabel × 2 hasThumbnail × 2 disabled=32 prop combinations with no invalid states. Property - Fix the
bodertypo across the token collection. Rename bothdefault/boderanderror/boder. Library-wide change — affects every variant. Token - Tokenize the thumbnail placeholder — replace hardcoded
#0057E4 @ 5%withmain/upload-file/color/default/thumbnail-bg. Token - Adopt a Figma Slot for the thumbnail — swappable preview image. Maps to
@ViewBuilder/@Composableslot for Code Connect. Slot - Reuse Labeled Field for the label + subtext scaffolding. Today Upload File reimplements the label-above + subtext-below pattern that Labeled Field already ships. Making Upload File an input slot inside Labeled Field reduces duplication. Composition
- Document the Lottie dependency — Progress bar is a Lottie animation. Native packages must bundle the animation JSON. Consider replacing with a native progress bar for a lighter dependency. Docs
Five states × hasLabel yes/no=10 variants. Showing each state at hasLabel=no; the label variant simply prepends a 14 / 14 label + 8px bottom spacing.
Empty state with paperclip + "Attach file / photo" placeholder text. 2px border, white bg. Subtext below lists accepted formats.
Shows file name + 5px-tall Lottie progress bar + percentage. Height grows to 91px to accommodate the progress row.
File name (GCash_File.png) + trailing trash icon for removal.
52×52 thumbnail preview + truncated file name (New_GCash_Fi….jpeg) + trash. Recommended to split into state=uploaded + hasThumbnail: true.
Red 2px border + red error subtext ("Maximum file size: 20MB").
| State | Role | Token | Value |
|---|---|---|---|
| Default | bg | main/upload-file/color/default/bg | #FFFFFF |
| — | border | main/upload-file/color/default/bodertypo | #E5EBF4 |
| — | leading icon | main/upload-file/color/default/icon-leading | #6780A9 |
| — | trailing icon | main/upload-file/color/default/icon-trailing | #005CE5 |
| — | label | main/upload-file/color/default/label | #0A2757 |
| — | file name | main/upload-file/color/default/label-name | #005CE5 |
| — | progress label | main/upload-file/color/default/progress-label | #0A2757 |
| — | thumbnail bg | — (hardcoded #0057E4 @ 5%) not tokenized | — |
| Error | bg | main/upload-file/color/error/bg | #FFFFFF |
| — | border | main/upload-file/color/error/bodertypo | #D61B2C |
| — | leading icon | main/upload-file/color/error/icon-leading | #6780A9 |
| — | label | main/upload-file/color/error/label | #0A2757 |
| — | file name | main/upload-file/color/error/label-name | #005CE5 |
| — | error subtext | main/subtext-message/error/label | #D61B2C |
| Subtext | default label | main/subtext-message/primary/label | #6780A9 |
| Property | Token | Value |
|---|---|---|
| Container width | — | 304px |
| Input height (default/uploaded/error) | — | 72px |
| Input height (uploading) | — | 91px (adds progress row) |
| Border width | — | 2px |
| Corner radius | radius/radius-2 | 6px |
| Horizontal padding | — | 16px (12L / 16R for thumbnail) |
| Vertical padding | — | 24px |
| Icon → name gap | space/space-4 | 4px |
| Thumbnail size | — | 52 × 52 |
| Thumbnail → name gap | space/space-8 | 8px |
| Label → input gap | space/space-8 | 8px |
| Input → subtext gap | space/space-8 | 8px |
| Progress bar height | — | 5px |
| Progress bar width | — | 250px |
| Leading / trailing icon size | — | 24 × 24 |
| Element | DS text style | Spec |
|---|---|---|
| Label | Primary/Label/Light/Small | HeyMeow Rnd Semibold · 14 / 14 · +0.25 |
| File name / placeholder | Primary/Label/Light/Large | HeyMeow Rnd Semibold · 18 / 18 · +0.25 |
| Subtext | Secondary/Bold/Caption | BarkAda Semibold · 12 / 18 |
| Progress percentage | Secondary/Bold/Small Caption | BarkAda Semibold · 10 / 15 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios" // Requires: lottie-ios for progress animation "https://github.com/airbnb/lottie-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:upload-file:1.0.0") // Requires: lottie-compose for progress animation implementation("com.airbnb.android:lottie-compose:6.4.0") }
| Current Figma | Proposed | SwiftUI | Compose |
|---|---|---|---|
| state=Default/Uploading/Uploaded/Upload error | state: EBUploadState | state: .default / .uploading / .uploaded / .error | state=EBUploadState.* |
| state=Uploaded with thumbnail | state=uploaded + hasThumbnail | .hasThumbnail(true) | hasThumbnail=true |
| hasLabel=yes/no | label: String? | label: String? | label: String? |
| — | fileName: String? | fileName: String? | fileName: String? |
| — | progress: Double | progress: Double (0.0–1.0) | progress: Float (0f–1f) |
| thumbnail placeholder | Figma Slot → ViewBuilder | @ViewBuilder thumbnail | thumbnail: @Composable () -> Unit |
| — | disabled: Bool | .disabled(true) | enabled=false |
| — | onSelect / onRemove | onSelect / onRemove | onSelect / onRemove |
// Default — empty state EBUploadFile(label: "Proof of ID", onSelect: { url in // handle picked file }) // Uploading EBUploadFile(fileName: "GCash_File.png", progress: 0.2) .ebState(.uploading) // Uploaded with thumbnail (Figma Slot) EBUploadFile(fileName: "ID_proof.jpg", onRemove: { ... }) { AsyncImage(url: imageURL) .aspectRatio(contentMode: .fill) .clipShape(RoundedRectangle(cornerRadius: 4)) } .ebState(.uploaded) // Error EBUploadFile(label: "Upload receipt", errorMessage: "Maximum file size: 20MB") .ebState(.error)
// Default — empty state EBUploadFile( label = "Proof of ID", onSelect = { uri -> /* handle picked file */ } ) // Uploading EBUploadFile( state = EBUploadState.Uploading, fileName = "GCash_File.png", progress = 0.2f ) // Uploaded with thumbnail (Figma Slot) EBUploadFile( state = EBUploadState.Uploaded, fileName = "ID_proof.jpg", onRemove = { /* ... */ } ) { AsyncImage( model = imageUrl, contentDescription = null, modifier = Modifier.clip(RoundedCornerShape(4.dp)) ) } // Error EBUploadFile( state = EBUploadState.Error, label = "Upload receipt", errorMessage = "Maximum file size: 20MB" )
| Requirement | iOS | Android |
|---|---|---|
| Role | .accessibilityAddTraits(.isButton) when empty; announce as "Upload" when actionable | Role.Button in semantics |
| File picked announcement | Announce file name after selection via .accessibilityAnnouncement | AccessibilityManager.announce() |
| Progress announcement | .accessibilityValue("\(Int(progress * 100)) percent") | stateDescription="$percent percent" |
| Error announcement | Include error message in accessibility label; use .isRejected trait | semantics { error(...) } |
| Remove button | Separate accessibility element: .accessibilityLabel("Remove \(fileName)") | contentDescription="Remove $fileName" |
| Tap target | 72px height > 44pt minimum | > 48dp minimum |
Do
Use the thumbnail slot for image uploads (ID photos, receipts) so users can verify the correct file was picked.
Don't
Show a generic thumbnail placeholder as the final state — either show the real thumbnail or use the plain uploaded state with just the filename.
Do
Always pair the default state with subtext listing accepted formats and size limits so users don't discover constraints only via error state.
Don't
Let users attempt uploads silently only to show an error — preempt format / size violations on the client side.
Do
Use the error state for client-side validation failures (size, format). Show a specific error message indicating what needs to change.
Don't
Use the error state for network failures during upload — those are transient. Show a toast or retry affordance instead.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic: input-field, Attach, Trash, Icon Placeholder, upload-file-progress, Subtext Message. |
| C2 | Variant & Property Naming | Needs Fix | hasLabel yes/no, state has "Upload error" with space, "Uploaded with thumbnail" is orthogonal. |
| C3 | Token Coverage | Needs Fix | Library-wide boder token typo. Thumbnail bg hardcoded. |
| C4 | Native Mappability | Ready | Maps to PhotosPicker / DocumentPicker (iOS), GetContent / PickVisualMedia (Android). |
| C5 | Interaction State Coverage | Needs Fix | No disabled, pressed, or focused states. |
| C6 | Asset & Icon Quality | Partial | Thumbnail is a placeholder; Lottie dependency needs documentation. |
| C7 | Code Connect Linkability | Pending | Blocked by C2 cleanup. |
5 state × 2 hasLabel=10 variants. Clean matrix — every combination exists.
| State | hasLabel | Count |
|---|---|---|
| Default | yes + no | 2 |
| Uploading | yes + no | 2 |
| Uploaded | yes + no | 2 |
| Upload error | yes + no | 2 |
| Uploaded with thumbnail | yes + no | 2 |
View full State × hasLabel breakdown (10 rows)
| state | hasLabel | Height | Node ID |
|---|---|---|---|
| Default | no | 98px | 18482:35065 |
| Default | yes | 120px | 18482:35071 |
| Uploading | no | 117px | 18482:35084 |
| Uploading | yes | 139px | 18482:35097 |
| Uploaded | no | 98px | 18482:35119 |
| Uploaded | yes | 120px | 18482:35126 |
| Upload error | no | 98px | 18482:35142 |
| Upload error | yes | 120px | 18482:35148 |
| Uploaded with thumbnail | no | 98px | 18482:35163 |
| Uploaded with thumbnail | yes | 120px | 18482:35173 |
hasLabel=yes/no, state="Upload error" has a space, "Uploaded with thumbnail" is orthogonal to the state axis. Openboder. Library-level rename needed. Open#0057E4 @ 5% not tokenized. OpenA 162-wide vertical voucher tile composed of a Voucher Asset image (stacked at two fixed sizes — large 153h and small 100h — both nested in the same symbol and toggled via booleans) plus a content block with hardcoded title, description, price (PHP 100.00 with PHP 150.00 strikethrough), validity period, and two rows of hardcoded status badges ("Limited" / "Expiring" in row 1, "Hot" / "Discounted" in row 2). 1 symbol, no variants, 8 boolean toggles. All text and badge labels are frozen placeholder strings.
5121:4533), and Voucher Card Horizontal (5119:1786) are three parallel records of the same component. Merge into a single Voucher Card with orientation: vertical | horizontal, state: default | limited | expiring | used | expired (borrowed from Voucher Card Horizontal), an image Slot that accepts Voucher Asset instances, and text slots for title / description / price / original price / validity. Drop the 8 booleans in favor of a single assetSize: large | small enum and a proper badges: [Badge] array.Single symbol, 162 × 465. Both asset frames (large 153h, small 100h) ship stacked in the default state. Content block below holds 2 fixed rows of hardcoded badges, a hardcoded title, description, price, and validity period. Every text string is placeholder; every badge label is frozen.
main/vouchers/color/default/*. But it ships two asset frames bundled together, assuming consumers will turn one off — nothing enforces the mutual exclusion.state axis (Default/Limited/Expiring/Used/Expired); Vertical Voucher ships none. Property shape diverges across the family.| Aspect | iOS | Android | Figma | Notes |
|---|---|---|---|---|
| Asset size | assetSize: .large | .small | assetSize=Large | Small | largeAsset + smallAsset booleans | Two booleans for a mutually exclusive choice. Should be a single enum. |
| Title | title: String | title: String | header boolean (string hardcoded) | String is frozen in the symbol. Boolean only toggles visibility. |
| Price / original | price: String, originalPrice: String? | Same | amount boolean (strings hardcoded) | "PHP 100.00" and "PHP 150.00" frozen; one boolean toggles both. |
| Validity | validity: String? | Same | validityPeriod boolean (string hardcoded) | "Validity: Dec 25 2022 - Jan 5 2023" frozen. |
| Status badges | badges: [EBBadge] | badges: List<EBBadge> | prop1stRowBadges + prop2ndRowBadges | Two fixed rows of 2 fixed badge labels each. Row-level visibility only — consumers can't pick which badges to render. |
| State | state: .default | .limited | .expiring | .used | .expired | Same enum | Not modelled | Absent entirely. Voucher Card Horizontal has it; Vertical Voucher does not. |
| Tap target | Entire card as Button with PlainButtonStyle | Card with onClick + ripple | Not modelled | Vouchers are always tappable; current symbol has no pressed/disabled states. |
- Voucher content tokens exist. Background (
main/vouchers/color/default/bg), title (label-title), description (label-description), amount (label-amount), strikethrough amount (label-amount-original), and metadata (label-metadata) are all bound to the voucher component's variable collection. C3 · Token Coverage
- Three parallel components for one concept. Vertical Voucher, Horizontal Voucher (
5121:4533), and Voucher Card Horizontal (5119:1786) share the same anatomy — voucher asset image + title + description + price + validity + status badges — but ship as three separate components with divergent property shapes. This is a family-level consolidation, not a single-component fix. C4 · Native Mappability - No state axis. Voucher Card Horizontal ships Default / Limited / Expiring / Used / Expired as a proper state variant that drives background, label colors, and badge treatment. Vertical Voucher has no state concept — a used or expired vertical voucher cannot be rendered in greyed-out treatment. C5 · Interaction State Coverage
- All text content is hardcoded placeholder. Title "Buy Load Pre-seeded SKU Voucher Sample", description "This is the description of the voucher.", price "PHP 100.00", original price "PHP 150.00", and validity "Validity: Dec 25 2022 - Jan 5 2023" are all frozen strings inside the symbol. Booleans toggle visibility but not content. Consumers cannot render a real voucher without detaching. C2 · Variant & Property Naming
- Two asset sizes bundled in one symbol. The symbol contains both the large (153h) and the small (100h) Voucher Asset instances stacked;
largeAssetandsmallAssetbooleans turn them on/off. The default 465h render shows both. This is a single enum (assetSize: large | small) masquerading as two booleans with implied mutual exclusion. C2 · Variant & Property Naming - Badges are row-level, not array-level.
prop1stRowBadgesandprop2ndRowBadgestoggle rows of two hardcoded badges each ("Limited" + "Expiring" / "Hot" + "Discounted"). A real voucher with one "Limited" badge and nothing else cannot be rendered. Badges should be a composable array, not fixed rows. C2 · Variant & Property Naming - Layer structure is flat and unlabeled.
large asset,small asset,content,badges,badges(duplicate),price. Two layers namedbadges, no semantic names for the title/description/validity text nodes. Consumers inspecting the Dev Mode output see anonymous text layers. C1 · Layer Structure & Naming - Voucher Asset nested image is raster with hardcoded "35% off" Badge. Inherits all the issues of Voucher Asset (
5119:1664) — the discount amount is baked into the image-frame variant, not a property on the parent. A voucher offering "50% off" or "BUY1 TAKE1" cannot be rendered. C6 · Asset & Icon Quality - No native component maps to this shape. 8 booleans with hardcoded content do not map to any reasonable native API. A proper
EBVoucherCardtakes title, price, validity, badges array, and image as parameters — not eight visibility toggles. Code Connect has no 1:1 target. C4 · Native Mappability - Code Connect cannot link an 8-boolean symbol with frozen strings. Even if a mapping existed, swapping the "title" string or the badge labels would require detaching the component. Linkability requires real string/array properties first. C7 · Code Connect Linkability
- Merge the three voucher cards into a single Voucher Card component. Vertical Voucher + Horizontal Voucher + Voucher Card Horizontal become one component with
orientation: vertical | horizontal(swaps the layout axis) andstate: default | limited | expiring | used | expired(borrowed from Voucher Card Horizontal). Target shape: 2 orientations × 5 states=10 variants instead of three separate components with divergent schemas. Family - Promote every text string to a property. Add
title: String,description: String,price: String,originalPrice: String?,validity: String?. Retire theheader/amount/description/validityPeriodbooleans — visibility falls out of whether the string is empty. Property - Replace the two asset-size booleans with one
assetSizeenum.largeAsset+smallAssetbecomeassetSize: large | small | none. Nothing currently prevents both being on simultaneously (which is the default render). A single enum makes the choice explicit and mutually exclusive. Property - Adopt a Figma Slot for the voucher image. Replace the hardcoded Voucher Asset nested instance with a Slot that accepts any Voucher Asset variant (or a partner brand illustration). The discount badge should be composed on top of the slot, not baked into the asset. Slot
- Replace fixed badge rows with a composable badges array. Drop
prop1stRowBadges/prop2ndRowBadges. Expose a badges Slot that accepts 0..n Badge instances, wraps when it runs out of width. Consumers choose which badges apply ("Limited" alone, "Hot" + "Discounted" + "New", etc.). Slot - Add the state axis missing from Vertical Voucher. Used and Expired vouchers render in greyed-out treatment with muted labels and a dim asset overlay — a pattern Voucher Card Horizontal already ships. Port the same 5-state treatment to the unified Voucher Card. State
- Rename duplicated
badgeslayers. The two badge-row frames are both namedbadges. After the consolidation above they should collapse into a singlebadges-slotlayer; until then, name thembadges-row-1/badges-row-2to disambiguate. Rename - Document that Voucher Card is the tap target. Vouchers are always tappable entry points to the voucher detail screen. The unified component should ship a pressed/focused state on the card frame; the handoff is an
onTapclosure, not an internal CTA button. Docs
Native models the voucher family as a single EBVoucherCard with orientation, state, text properties, and a Voucher Asset image parameter. The current Vertical Voucher's 8 booleans do not have a 1:1 native analog; consumers write real strings and state enums instead.
// Proposed API — unified Voucher Card (vertical orientation) EBVoucherCard( orientation: .vertical, state: .limited, title: "Buy Load Voucher", description: "Get a discount on any prepaid load.", price: "PHP 100.00", originalPrice: "PHP 150.00", validity: "Dec 25 2022 - Jan 5 2023", badges: [ EBBadge("Limited", style: .informationHeavy), EBBadge("Expiring", style: .negativeHeavy) ] ) { EBVoucherImageFrame(size: .large, discount: "35% off") { Image("voucher-food").resizable().scaledToFill() } } .onTapGesture { // navigate to voucher details } // Used/expired treatment EBVoucherCard(orientation: .vertical, state: .used, title: "Redeemed", price: "PHP 100.00") { EBVoucherImageFrame(size: .small) { Image("voucher-food").resizable().scaledToFill() } }
// Proposed API — unified Voucher Card (vertical orientation) EBVoucherCard( orientation = EBVoucherOrientation.Vertical, state = EBVoucherState.Limited, title = "Buy Load Voucher", description = "Get a discount on any prepaid load.", price = "PHP 100.00", originalPrice = "PHP 150.00", validity = "Dec 25 2022 - Jan 5 2023", badges = listOf( EBBadge("Limited", style = EBBadgeStyle.InformationHeavy), EBBadge("Expiring", style = EBBadgeStyle.NegativeHeavy) ), onClick = { /* navigate to voucher details */ }, image = { EBVoucherImageFrame(size = EBVoucherImageFrameSize.Large, discount = "35% off") { Image( painter = painterResource(R.drawable.voucher_food), contentDescription = null, contentScale = ContentScale.Crop ) } } )
The current 8 booleans do not map cleanly to native. The table below shows the target shape after the family consolidation — each row captures what the proposed EBVoucherCard replaces from the current Vertical Voucher.
| Current Figma | Proposed Figma | SwiftUI | Compose | Notes |
|---|---|---|---|---|
| — | orientation | orientation: EBVoucherOrientation | orientation=EBVoucherOrientation | vertical | horizontal — collapses 3 components into 1 |
| — | state | state: EBVoucherState | state=EBVoucherState | default | limited | expiring | used | expired (port from Voucher Card Horizontal) |
largeAsset + smallAsset | assetSize | assetSize: .large | .small | .none | Same | Two booleans → one enum |
header (boolean, string frozen) | title (string) | title: String | title: String | Visibility=whether string is empty |
description (boolean, string frozen) | description (string) | description: String? | Same | Same |
amount (boolean, PHP 100 / PHP 150 frozen) | price + originalPrice | price: String, originalPrice: String? | Same | Two strings, strikethrough applied to originalPrice |
validityPeriod (boolean, string frozen) | validity (string) | validity: String? | Same | Same |
prop1stRowBadges + prop2ndRowBadges | badges Slot | badges: [EBBadge] | badges: List<EBBadge> | Composable array, wraps on overflow |
| nested Voucher Asset | Image Slot | trailing closure | image: @Composable () -> Unit | Accepts any EBVoucherImageFrame instance |
| — | — | onTap: () -> Void | onClick: () -> Unit | Card is the tap target |
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Two frames both named badges. Text layers are unnamed. Asset frames large asset / small asset should be one frame with an assetSize property. |
| C2 | Variant & Property Naming | Rework | 8 booleans where most should be strings (title, price, validity) or a Slot (badges). largeAsset + smallAsset should be one enum. All content is frozen placeholder. |
| C3 | Token Coverage | Ready | Background, title, description, amount, strikethrough amount, and metadata are all bound to main/vouchers/color/default/*. Typography uses named text styles (Primary/Multi-line Label/Base, Primary/Label/Small, Secondary/Default/Caption, Secondary/Default/Fine). |
| C4 | Native Mappability | Rework | Parallel to 2 other voucher components with divergent schemas. Native has one EBVoucherCard, not three. 8 booleans with frozen strings have no native analog. |
| C5 | Interaction State Coverage | Rework | No state axis at all. Voucher Card Horizontal ships Default/Limited/Expiring/Used/Expired; Vertical Voucher has none. No pressed/focused/disabled on the card frame either. |
| C6 | Asset & Icon Quality | Rework | Inherits Voucher Asset's raster + hardcoded "35% off" badge. The discount amount is not a property on the parent Voucher. |
| C7 | Code Connect Linkability | Rework | Cannot map. Frozen strings, row-level badge toggles, and two-boolean asset size do not have 1:1 native parameters. Linkability requires the family consolidation first. |
Single symbol, no variant axes declared. All configurability is through 8 boolean property toggles on the lone instance.
| Node ID | Name | Dimensions | Property toggles |
|---|---|---|---|
5119:1635 | Vertical Voucher | 162 × 465 (with both assets + all content on) | amount, description, header, largeAsset, prop1stRowBadges, prop2ndRowBadges, smallAsset, validityPeriod — all boolean, all default true |
Nested instances (both carry their own hardcoded "35% off" Badge):
| Node ID | Layer | Kind | Dimensions |
|---|---|---|---|
5119:1637 | large asset > Voucher Asset | Voucher Asset instance | 162 × 153 |
5119:1639 | small asset > Voucher Asset | Voucher Asset instance | 162 × 100 |
5119:1642 | badges > Badge "Limited" | Badge instance (information/heavy) | auto |
5119:1643 | badges > Badge "Expiring" | Badge instance (negative/heavy) | auto |
5119:1645 | badges > Badge "Hot" | Badge instance (destructive) | auto |
5119:1646 | badges > Badge "Discounted" | Badge instance (brand/heavy) | auto |
5121:4533) and Voucher Card Horizontal (5119:1786). OpenVoucher Card with orientation + state axes, text slots (title, description, price, originalPrice, validity), a badges array, and a voucher image Slot. Target: 2 × 5=10 variants instead of 3 divergent components. OpenA read-only data display field with a muted label, prominent value, optional subtext description, and an optional trailing slot (badge, text link, or icon). 8 variants across variant (Default / with Badge / with Text Link / with Icon) × Size (Default / Large). Part of the Form Elements group — used in profile screens, transaction details, account information displays, and read-only settings.
variant is overloaded with 4 trailing content types — consider renaming to trailingContent (C2). Checkmark uses raster IMG instead of a vector icon (C6).Contexts are illustrative. Final screens will reference actual GCash patterns.
Toggle variant and size to see the view-only field update in real time.
variant is overloaded — conflates 4 different trailing content types (none/badge/textLink/icon) into one enum. Should be renamed to trailingContent. Size=Default isn't a size name — should be Regular.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | variant=Default | Label + value only, no trailing slot. |
| with Badge | Yes | Yes | variant=with Badge | Badge instance in the trailing slot (e.g. "Change" status chip). |
| with Text Link | Yes | Yes | variant=with Text Link | Text link in the trailing slot (e.g. "What is this?" contextual help). |
| with Icon | Yes | Yes | variant=with Icon | Edit icon (pencil) in the trailing slot — typically navigates to an editable state. |
| Checkmark | Yes | Yes | hasCheckmark=true | Displays a 13×13 checkmark next to the value (e.g. verified status). |
| Description | Yes | Yes | hasDescription=true | Shows a subtext message below the value (e.g. "Message content" helper text). |
- Property
variantis overloaded. Conflates 4 different trailing content types (none, badge, text link, icon) into a single enum. Better expressed as atrailingContentproperty with semantic values, or split into separate boolean properties per slot. C2 · Variant & Property Naming - Property value
Size=Defaultisn't a size name. "Default" describes the starting state, not a size. Rename toRegular(orSmall) for consistency with standard size naming across the DS. C2 · Variant & Property Naming - Checkmark is a raster image. Uses
imgCheckfrom Figma CDN instead of a vector icon instance. Replace with a vector from the DS icon library for clean cross-platform rendering at any DPI. C6 · Asset & Icon Quality - Code Connect mappings not registered. Blocked until the
variantrename and asset fix land. C7 · Code Connect Linkability
- Rename
varianttotrailingContent. Values:none/badge/textLink/icon. Clearer intent, cleaner native enum mapping, no invalid combinations. Rename - Rename
Size=DefaulttoSize=Regular. (OrSize=Small, depending on where it sits in the size scale.) Aligns with the Small/Medium/Large convention other components use. Rename - Replace the raster checkmark with a vector icon instance. Drop in a real Icon from the DS icon library — ensures crisp rendering at any DPI on iOS and Android. Asset
- Expose
label,value, anddescriptionas text overrides. Designers can then customize copy without editing the master component. Property - Add an error/warning state. Read-only fields sometimes need to convey validation status (e.g. "verification pending", "expired"). A
statusprop coveringnone/warning/errormakes this explicit. State
4 variants across 2 sizes=8 total. All share the same base layout (label + value + optional subtext) with different trailing slot content. Large size uses bigger, bolder value typography.
Label + value only. Used for plain read-only information display.
Label + value + Badge instance in the trailing slot. Uses Badge component (layout=overflow or similar) for status indicators.
Label + value + contextual text link (e.g. "What is this?"). Used for helper/learn-more navigation.
Label + value + 24×24 icon (typically Edit pencil) in the trailing slot. Icon typically navigates to an editable state.
Display-only component — no interaction states. All colors token-bound.
| Role | Token | Value |
|---|---|---|
| Label | main/view-only-field/color/label | #6780A9 |
| Value text | main/view-only-field/color/text | #0A2757 |
| Text link | main/view-only-field/color/label-link | #005CE5 |
| Edit icon | main/view-only-field/color/icon | #005CE5 |
| Subtext description | main/subtext-message/primary/label | #6780A9 |
| Badge bg (default) | main/badge/information/light/background | #E5F1FF |
| Badge label | main/badge/information/light/label | #005CE5 |
| Property | Default | Large |
|---|---|---|
| Height | 57px | 71px |
| Width | 360px | 360px |
| Label-value gap | 8px | 8px |
| Subtext top padding | 4px | 4px |
| Trailing icon size | 24 × 24 | 24 × 24 |
| Checkmark size | 13 × 13 | 13 × 13 |
| Layer | Default Size | Large Size |
|---|---|---|
| Label | Primary/Label/Light/Small — 14px Semibold | Primary/Label/Light/Base — 16px Semibold |
| Value text | Primary/Label/Light/Base — 16px Semibold | Primary/Headlines/Section — 22px Bold |
| Subtext description | Secondary/Bold/Small Caption — 10px Semibold (BarkAda) | Secondary/Bold/Caption — 12px Semibold (BarkAda) |
| Text link | 12px Semibold (BarkAda) | 12px Semibold (BarkAda) |
| Badge label | Primary/Label/Fine — 12px Bold | Primary/Label/Fine — 12px Bold |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:form-elements:1.0.0") }
Package not yet published. These are the planned distribution paths.
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| variant=Default | (default — no trailing) | trailing=null | No trailing slot content |
| variant=with Badge rename | trailing: { EBBadge(...) } | trailing={ EBBadge(...) } | Pass a Badge composable to the trailing slot |
| variant=with Text Link rename | trailing: { Button("...") {} } | trailing={ TextButton(...) } | Pass a text button |
| variant=with Icon rename | trailing: { Image(...) } | trailing={ Icon(...) } | Pass an icon (tap handler optional) |
| Size=Default rename | .controlSize(.regular) | size=EBFieldSize.Regular | Rename Size=Default to Size=Regular |
| Size=Large | .controlSize(.large) | size=EBFieldSize.Large | Bigger, bolder value typography |
| hasCheckmark (boolean) | isVerified: Bool | isVerified: Boolean | Shows checkmark next to value |
| hasDescription (boolean) | description: String? | description: String? | Optional subtext below value |
EBViewOnlyField( label: "Mobile Number", value: "+63 917 123 4567", description: "This is your verified number" )
EBViewOnlyField( label = "Mobile Number", value = "+63 917 123 4567", description = "This is your verified number" )
EBViewOnlyField( label: "Account Status", value: "Active", trailing: { EBBadge("Change", state: .information, level: .light) } )
EBViewOnlyField( label = "Account Status", value = "Active", trailing = { EBBadge( text = "Change", state = BadgeState.Information, level = BadgeLevel.Light ) } )
EBViewOnlyField( label: "Email Address", value: "dhar@frostdesigngroup.com", trailing: { Button(action: { /* navigate to edit */ }) { Image(systemName: "pencil") } } )
EBViewOnlyField( label = "Email Address", value = "dhar@frostdesigngroup.com", trailing = { IconButton(onClick = { /* navigate to edit */ }) { Icon(Icons.Default.Edit, contentDescription = "Edit") } } )
| Requirement | iOS | Android |
|---|---|---|
| Accessibility label | .accessibilityLabel("\(label): \(value)") | contentDescription="$label: $value" |
| Trailing action (with Icon) | Button with .accessibilityLabel("Edit \(label)") | IconButton with contentDescription="Edit $label" |
| Text link | Button with .accessibilityHint("Opens help") | TextButton with semantics { role=Role.Button } |
| Min touch target (trailing action) | 44 × 44 pt | 48 × 48 dp |
Do
Use View Only Field to display verified or system-set data (phone number, email, account status) where the user shouldn't edit directly.
Don't
Use for editable input — use Input Field, Labeled Field, or Select Field instead. Read-only fields imply the value is final or managed elsewhere.
Do
Use the with Icon variant with a pencil to indicate the field can be edited in a separate screen — provides a clear tap target.
Don't
Rely on the icon alone without an accessibility label — screen readers need to announce the trailing action's purpose.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic names: container, content-container, text-container, badge-container, text-link-container, icon-container. Clean hierarchy. |
| C2 | Variant & Property Naming | Partial | Property variant is overloaded (4 trailing content types as one enum). Size=Default isn't a size name — should be Regular. |
| C3 | Token Coverage | Ready | All colors bound to design tokens. Space, typography, and badge tokens all present. |
| C4 | Native Mappability | Ready | Maps cleanly to SwiftUI VStack / Compose Column with label + value + optional trailing closure. |
| C5 | Interaction State Coverage | Ready | Display-only component — no interaction states expected. Trailing actions (icon/text link) handle their own tap states. |
| C6 | Asset & Icon Quality | Partial | Checkmark uses a raster IMG from Figma CDN. Edit icon is a clean vector. Replace checkmark with an icon component instance. |
| C7 | Code Connect Linkability | Pending | No CLI mappings registered yet. |
4 variant values × 2 Size values. Two booleans (hasCheckmark, hasDescription) apply to all variants.
| variant | Size | Height | Node ID |
|---|---|---|---|
| Default | Default | 57 | 18403:4521 |
| with Badge | Default | 57 | 18403:4533 |
| with Text Link | Default | 57 | 18403:4547 |
| with Icon | Default | 57 | 18403:4561 |
| Default | Large | 71 | 18403:4575 |
| with Badge | Large | 71 | 18403:4587 |
| with Text Link | Large | 71 | 18403:4601 |
| with Icon | Large | 71 | 18403:4615 |
variant conflates 4 different trailing content types into one enum. Should be renamed to trailingContent or split into semantic properties. OpenA modal with a hero image, title, description, and one or more CTAs. Used to display additional information, gather user input, or seek confirmation for critical actions. 3 variants: Default (single CTA), 2 CTA (primary + secondary), Version 2 (preamble, close icon, content-first layout for onboarding).
Default / 2 CTA / Version 2 (C2). Hero image is a raster placeholder with "Replace me" overlay instead of a swappable image slot (C6). No destructive/error/loading state coverage (C5). Code Connect mappings not registered (C7).Contexts are illustrative. Final screens will reference actual GCash patterns. Visual Popup overlays the app surface to confirm critical actions or onboard users to a new feature.
Toggle Type to see each variant. Hero image, title, description, and CTA(s) update accordingly.
main/modal-popup/color/bg), Shadow/Depth 0, radius/radius-2, and 24px padding. Composes Button instances rather than redefining button styles.Default (generic), 2 CTA (count), Version 2 (version). Native enums need a single semantic axis — e.g. single-cta / dual-cta / dismissible. C2| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Single CTA | Yes | Yes | Type=Default | Hero (180px) + title + description + primary CTA. Use for info or single-action confirm. |
| Dual CTA | Yes | Yes | Type=2 CTA | Adds a secondary outline + tertiary text button below primary. Use for cancel/confirm pairs. |
| Dismissible (V2) | Yes | Yes | Type=Version 2 | Preamble label + title with close icon + content-first layout. Use for onboarding/tutorial popups. |
| Destructive / Error / Loading | N/A | N/A | — | No variants for destructive confirms or async/loading states. C5 |
- Variant naming mixes paradigms.
Default(generic),2 CTA(count), andVersion 2(version) can't coexist as one enum. Collapse to a single semantic axis:single-cta/dual-cta/dismissible. C2 · Variant & Property Naming - No destructive / error / loading state coverage. Engineers must improvise these for "Cancel transaction?", error confirms, and async submit flows. C5 · Interaction State Coverage
- Hero image is a raster placeholder with "Replace me" overlay. Should be a swappable Image slot (component instance) so product teams override per-popup without editing the master. C6 · Asset & Icon Quality
- Code Connect mappings not registered. Blocked until the variant naming and asset-slot issues above are resolved. C7 · Code Connect Linkability
- Collapse
Typeto one semantic enum. Values:single-cta,dual-cta,dismissible. Eliminates the version/count/default mix and maps cleanly to anEBVisualPopupKindenum. Property - Replace the raster
Modals Assetwith a swappable Image slot. A component placeholder that product teams can instance-swap with their illustration — matches the pattern Avatar uses for itsimagetype. Slot - Add a
destructivemode. Whether as a boolean or akind=destructivevariant — lets destructive confirms (Cancel / Logout / Delete) use red CTAs without bespoke overrides. State - Add a
loadingstate for the primary CTA. Async submits in popups currently have no documented affordance — reuse the planned Button loading state rather than invent a new pattern. State
3 layouts. Default and 2 CTA share the same hero-on-top structure; Version 2 inverts to content-first with the hero embedded inside a light-gray container.
Hero image (320 × 180, 16:9) + title + 2-line description + single primary CTA. Use for informational modals or single-action confirms ("Okay").
Same hero + title + description as Default, then a secondary outline button on top of a tertiary text button. Use for confirm/cancel pairs.
Onboarding/tutorial layout. The popup itself is a single light-gray (bg/color-bg) container — preamble label, title with close icon, description, a 280×180 hero image with 10px radius, then primary CTA. (The outer white frame has zero padding, so only the gray container is visible.)
Modal popup ships display-only color tokens — no pressed/disabled states (the popup itself doesn't have interaction states; CTAs handle that via Button tokens).
| Role | Token | Value |
|---|---|---|
| Modal background | main/modal-popup/color/bg | #FFFFFF |
| Title label | main/modal-popup/color/label | #0A2757 |
| Description label | main/modal-popup/color/label-primary | #6780A9 |
| Preamble (V2) | main/modal-popup/color/label-preamble | #90A8D0 |
| Close icon (V2) | main/modal-popup/color/icon-close | #6780A9 |
| V2 inner container | bg/color-bg | #F6F9FD |
| Property | Token | Value |
|---|---|---|
| Default / 2 CTA width | — | 320px |
| Version 2 width | — | 312px |
| Hero image (Default / 2 CTA) | — | 320 × 180 (16:9) |
| Hero image (V2) | — | 280 × 180, 10px radius |
| Body padding | space/space-24 | 24px |
| CTA group padding (vertical) | space/space-24 | 24px |
| 2 CTA gap between buttons | space/space-8 | 8px |
| V2 inner container padding | space/space-16 | 16px h, 16t / 24b |
| Corner radius | radius/radius-2 | 6px |
| Shadow | Shadow/Depth 0 | 0 0 4px #E8EEF2C9 |
| Close icon (V2) | — | 24 × 24 |
| Element | DS text style | Spec |
|---|---|---|
| Title | Primary/Headlines/Section | HeyMeow Rnd Bold · 22 / 26 |
| Description | Secondary/Default/Base | BarkAda Medium · 14 / 20 |
| Preamble (V2) | Primary/Label/Tiny | HeyMeow Rnd Bold · 10 / 10 · +0.25 |
| CTA label | Primary/Label/Large | HeyMeow Rnd Bold · 18 / 18 · +0.25 |
iOS — Swift Package Manager
// In Xcode: File → Add Package Dependencies "https://github.com/AY-Org/eb-ds-ios"
Android — Gradle (Kotlin DSL)
dependencies { implementation("com.eastblue.ds:visual-popup:1.0.0") }
| Figma Property | SwiftUI Param | Compose Param | Notes |
|---|---|---|---|
| Type=Default | .ebKind(.singleCTA) | kind=EBVisualPopupKind.SingleCTA | Hero + title + description + primary CTA |
| Type=2 CTA | .ebKind(.dualCTA) | kind=EBVisualPopupKind.DualCTA | Adds secondary outline + tertiary text button |
| Type=Version 2 | .ebKind(.dismissible) | kind=EBVisualPopupKind.Dismissible | Preamble + close icon + content-first layout |
| Hero image (raster) | heroImage: Image | heroImage: Painter | Currently a placeholder; should become a swappable slot |
| CTA buttons | primary / secondary / tertiary: EBButton | primary / secondary / tertiary: @Composable | Compose Button instances directly |
// Default — single CTA EBVisualPopup( title: "Cash In Successful", description: "₱500.00 added to your wallet.", heroImage: Image("cash-in-success"), primary: EBButton("Okay") { /* dismiss */ } ) .ebKind(.singleCTA) // 2 CTA — confirm/cancel EBVisualPopup( title: "Cancel transaction?", description: "This cannot be undone.", heroImage: Image("warning-illustration"), primary: EBOutlinedButton("Confirm") { /* confirm */ }, secondary: EBTextButton("Go Back") { /* dismiss */ } ) .ebKind(.dualCTA) // Version 2 — onboarding with close icon EBVisualPopup( preamble: "NEW", title: "Save your receipts", description: "Tap any transaction to save its receipt.", heroImage: Image("receipt-tutorial"), primary: EBButton("Got it") { /* dismiss */ }, onClose: { /* dismiss */ } ) .ebKind(.dismissible)
// Default — single CTA EBVisualPopup( kind = EBVisualPopupKind.SingleCTA, title = "Cash In Successful", description = "₱500.00 added to your wallet.", heroImage = painterResource(R.drawable.cash_in_success), primary = { EBButton("Okay", onClick = { /* dismiss */ }) } ) // 2 CTA — confirm/cancel EBVisualPopup( kind = EBVisualPopupKind.DualCTA, title = "Cancel transaction?", description = "This cannot be undone.", heroImage = painterResource(R.drawable.warning), primary = { EBOutlinedButton("Confirm", onClick = { /* confirm */ }) }, secondary = { EBTextButton("Go Back", onClick = { /* dismiss */ }) } ) // Version 2 — onboarding with close icon EBVisualPopup( kind = EBVisualPopupKind.Dismissible, preamble = "NEW", title = "Save your receipts", description = "Tap any transaction to save its receipt.", heroImage = painterResource(R.drawable.receipt_tutorial), primary = { EBButton("Got it", onClick = { /* dismiss */ }) }, onClose = { /* dismiss */ } )
| Requirement | iOS | Android |
|---|---|---|
| Modal trait / role | Present via .sheet or .alert — VoiceOver announces as modal | Dialog announces as modal; TalkBack focus trapped inside |
| Focus trap | Automatic with .sheet | Automatic with Dialog — set dismissOnClickOutside=false for confirm popups |
| Close button label (V2) | .accessibilityLabel("Close") | contentDescription="Close" |
| Hero image | If decorative: .accessibilityHidden(true). If informative: provide a label. | Same — contentDescription=null for decorative, otherwise describe |
| Tap targets | CTAs use Button which meets HIG 44pt | CTAs meet Material 48dp |
| Destructive role | Currently undefined — needs role: .destructive when state lands | Currently undefined — needs Button destructive colors when state lands |
Do
Use Visual Popup for critical confirms, success states with celebration, and onboarding moments — places where the hero image adds emotional weight.
Don't
Use for inline form errors or transient notifications — those belong in Toast, Banner, or inline error patterns, not a blocking modal.
Do
Use the Default variant when the popup has one obvious next step (Okay, Got it, Continue).
Don't
Use 2 CTA when one button is clearly more important than the other — that's still a Default with the secondary action elsewhere.
Do
Use Version 2 (with close icon) only for onboarding/tutorial popups where the user can dismiss without taking action.
Don't
Add a close icon to confirm/destructive popups — forces the user to consciously choose the CTA.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Semantic names: Modals Asset, body, header, CTA - Base Button Group, Close. |
| C2 | Variant & Property Naming | Needs Fix | Property values mix paradigms: Default / 2 CTA / Version 2. Should be one semantic axis. |
| C3 | Token Coverage | Ready | All colors, spacing, radii, shadow, and typography bound to tokens. |
| C4 | Native Mappability | Ready | Maps to .sheet / .alert on iOS and Dialog / AlertDialog on Android. |
| C5 | Interaction State Coverage | Needs Fix | No destructive, error, or loading variants. Close affordance only on Version 2. |
| C6 | Asset & Icon Quality | Needs Fix | Hero is a flat raster placeholder with "Replace me" overlay. Should be a swappable Image slot. |
| C7 | Code Connect Linkability | Pending | No CLI mappings registered yet. |
| Type | Width | Hero | CTAs | Node ID |
|---|---|---|---|---|
| Default | 320px | 320 × 180 (raster) | 1 primary | 18477:23789 |
| 2 CTA | 320px | 320 × 180 (raster) | 1 outline + 1 text | 18477:23797 |
| Version 2 | 312px | 280 × 180 (raster, in container) | 1 primary + close icon | 18477:23806 |
Default (generic), 2 CTA (count), Version 2 (version). Should be a single semantic axis (single-cta / dual-cta / dismissible). OpenModals Asset image. Should be a swappable Image slot via instance swap. OpenA ticket-shaped raster image used as the visual for voucher placements — a full-bleed photo clipped into a notched-ticket frame, with a Badge instance anchored to the right edge displaying "35% off". The current component has four property axes: type (midfi | hifi), size (small | large), use case (10 category values: default, restaurant, vacation, beverage, snack, fashion, party, meal, games, food), and orientation (default | horizontal). 20 variants ship out of a possible 40 in the cartesian space. Every variant is a hardcoded image fill; the "35% off" string is baked in.
type=midfi|hifi (authoring fidelity, not product concern) and use case (content, not variant). Ship a single Voucher Image Frame with size: small | large + orientation: vertical | horizontal, plus an image Slot and a discount string. Category artwork lives in a separate asset library, instance-swapped into the Slot.20 variants across type × size × use case × orientation. The top row is the midfi "Placeholder image" wireframes; the rest are hifi raster photos per category. Every tile has the same hardcoded "35% off" Badge pinned to the right edge.
type=midfi is an authoring state, use case is content, size and orientation are geometry. Cartesian space is sparse — only default and food exist in horizontal; most use cases don't. New categories require new variants.| Aspect | iOS | Android | Figma | Notes |
|---|---|---|---|---|
| Image source | Image from asset catalog | Image from drawable resources | use case enum | Native doesn't switch on an enum — it reads a named asset. The Figma enum is a Figma-only crutch. |
| Size | Fixed frames: 162×100 (small), 162×153 (large) | Same | size enum | Two fixed sizes; horizontal variants override to 336×144. |
| Discount label | EBBadge("35% off", style: .brandHeavy) | EBBadge("35% off", style=BrandHeavy) | Badge instance | Currently hardcoded string. Should be a discount property on the parent component. |
| Clipping shape | Custom Path with notch cutouts | Custom Shape with notches | Mask | Ticket notch + dashed center line is the only DS-specific visual primitive here. |
- Use-case axis promotes illustration content into variants. 10 use-case values (restaurant, vacation, beverage, snack, fashion, party, meal, games, food, default) mean every new voucher category ships a new DS variant. This is the same anti-pattern Ad Space retired. Consumers should instance-swap an illustration into a Slot, not pick from a DS-owned enum. C2 · Variant & Property Naming
- Fidelity axis
type=midfi|hifiis not a product concern. Mid-fidelity "Placeholder image" wireframes vs hi-fidelity final photos is an authoring-workflow state, not a variant the product should expose. Native has no concept of "mid-fidelity"; this axis will not map. Same pattern retired on Ad Space. C2 · Variant & Property Naming - Cartesian space is sparse (20/40 shipped). 10 use cases × 2 sizes × 2 orientations=40 possible variants. Only 20 actually exist. Horizontal orientation only exists for
default(midfi wireframe) andfood(hifi GrabFood). Most use cases have no horizontal artwork. The matrix is authored ad-hoc, not systematically. C1 · Layer Structure & Naming - Discount amount "35% off" is hardcoded. The Badge instance inside every variant renders a fixed "35% off" string. There is no property to change the voucher discount — consumers with a 50% off or BUY1TAKE1 promo must detach. C2 · Variant & Property Naming
- All artwork is raster image fill. 19 of 20 variants are photographic images; 1 is a grey placeholder. No vector illustrations, no token-driven coloring. Images live inside the component, not in a separate asset library, which means they ship with every DS publish and bloat the library. C6 · Asset & Icon Quality
- No native component to map to. Native handoff for category illustration is an asset catalog entry (
Image("voucher-restaurant-large")) — not a Code-Connected DS component. Only the ticket frame + badge overlay warrants a component on native. The use-case enum has no iOS/Android correlate. C4 · Native Mappability - Code Connect cannot map a 10-value content enum. Even if a mapping existed, it would force 10 named image assets per size × orientation combination into the codebase, locked to the Figma enum. Any new category requires a Figma variant ship + a native code ship in lockstep. C7 · Code Connect Linkability
- Collapse into one Voucher Image Frame component. Retire
use caseandtype. Keepsize: small | largeandorientation: vertical | horizontal. Add an image Slot that accepts any illustration instance, plus adiscountstring (default "35% off") that drives the Badge. Target schema: 2 × 2=4 variants instead of 20. Property - Move category artwork to a separate Voucher Illustrations library. Ship a sibling library with named illustration instances organized by category (Food, Travel, Entertainment, etc.). Consumers instance-swap the illustration into the Voucher Image Frame's Slot. New categories are added to the library, not to the DS component. Family
- Adopt a Figma Slot for the image fill. The current pattern (hardcoded image fill per variant) prevents consumers from using branded partner artwork (GrabFood, Tim Hortons, etc. — already visible in current variants) without detaching. A Slot lets product teams bring their own asset. Slot
- Promote
discountto a string property. Extract the "35% off" text out of the variants so vouchers can show any discount format ("50% off", "BUY1 TAKE1", "₱100 OFF"). The Badge instance is already composable; only the string needs exposing. Property - Close the orientation matrix. Either commit to supporting horizontal for every size (ship the missing artwork through the Slot), or drop orientation as an axis and let parents set aspect ratio. The current sparse matrix (2 horizontal variants out of 20) signals the axis is accidental. Property
- Native handoff is asset catalog, not Code Connect. Document that voucher illustrations ship via
Assets.xcassets(iOS) andres/drawable/(Android). The DS component's Code Connect mapping should cover only the ticket frame + Badge + discount string; category art is a product-team responsibility. Docs
Native splits this into two layers: (1) EBVoucherImageFrame, the ticket-shape container with notch + dashed separator + Badge overlay, and (2) category artwork, which lives in the platform asset catalog and is passed in as an Image. The current Figma use case enum is not modelled natively — consumers pick the asset name directly.
// Proposed API — restructured component EBVoucherImageFrame( size: .large, orientation: .vertical, discount: "35% off" ) { Image("voucher-restaurant") // from asset catalog .resizable() .scaledToFill() } // Horizontal hero voucher EBVoucherImageFrame(size: .large, orientation: .horizontal, discount: "50% off") { Image("voucher-food-grabfood").resizable().scaledToFill() }
// Proposed API — restructured component EBVoucherImageFrame( size = EBVoucherImageFrameSize.Large, orientation = EBVoucherImageFrameOrientation.Vertical, discount = "35% off" ) { Image( painter = painterResource(R.drawable.voucher_restaurant), contentDescription = null, contentScale = ContentScale.Crop ) } // Horizontal hero voucher EBVoucherImageFrame( size = EBVoucherImageFrameSize.Large, orientation = EBVoucherImageFrameOrientation.Horizontal, discount = "50% off" ) { Image(painterResource(R.drawable.voucher_food_grabfood), null, contentScale = ContentScale.Crop) }
| Figma (proposed) | SwiftUI | Compose | Notes |
|---|---|---|---|
size | size: EBVoucherImageFrameSize | size=EBVoucherImageFrameSize | small (162×100) | large (162×153) |
orientation | orientation: .vertical | .horizontal | orientation=Vertical | Horizontal | Horizontal large is 336×144 |
discount (string) | discount: String | discount: String | Renders via EBBadge overlay |
| Image Slot | content: () -> Image | content: @Composable () -> Unit | Consumer-supplied illustration |
| — | — | Retired — asset name is consumer's choice | |
| — | — | Retired — authoring fidelity, not a product axis |
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | 20 variants with sparse orientation coverage — only default and food have horizontal artwork. Matrix is not closed. |
| C2 | Variant & Property Naming | Rework | use case is content, not a variant. type=midfi|hifi is authoring fidelity, not a product axis. "35% off" is hardcoded. |
| C3 | Token Coverage | Refine | Badge uses token-bound colors and typography. Image fills bypass tokens (raster). |
| C4 | Native Mappability | Rework | No native correlate for a 10-value illustration enum. Correct handoff is asset catalog + lean frame component. |
| C5 | Interaction State Coverage | N/A | Display-only artwork; no interactive states. |
| C6 | Asset & Icon Quality | Rework | All raster. 19/20 variants are photos; 1 placeholder. Assets should live in a sibling library, not the component. |
| C7 | Code Connect Linkability | Rework | Cannot map a 10-value content enum 1:1 to a native parameter. Linkability requires collapsing use case first. |
Current shape: type × size × use case × orientation. Cartesian space is 2 × 2 × 10 × 2=80, only 20 shipped. Heavily skewed toward type=hifi / orientation=Default (16 of 20). midfi exists only for use case=Default; every other use case has only hifi artwork.
| Use case | Sizes shipped | Orientations shipped | Count |
|---|---|---|---|
| Default (midfi) | small, large | Default, horizontal | 3 |
| Restaurant | small, large | Default | 2 |
| Vacation | small, large | Default | 2 |
| Beverage | small, large | Default | 2 |
| Snack | small, large | Default | 2 |
| Fashion | small, large | Default | 2 |
| Party | small, large | Default | 2 |
| Meal | small, large | Default | 2 |
| Games | small, large | Default | 2 |
| Food | large | horizontal | 1 |
| Total | 20 | ||
View full type × size × use case × orientation breakdown (20 rows)
| Node ID | type | size | use case | orientation | Dimensions |
|---|---|---|---|---|---|
5119:1665 | midfi | small | Default | Default | 162 × 100 |
5119:1669 | midfi | large | Default | Default | 162 × 153 |
5119:1673 | midfi | large | default | horizontal | 336 × 144 |
5119:1682 | hifi | small | restaurant | Default | 162 × 100 |
5119:1687 | hifi | large | restaurant | Default | 162 × 153 |
5119:1692 | hifi | small | vacation | Default | 162 × 100 |
5119:1697 | hifi | large | vacation | Default | 162 × 153 |
5119:1702 | hifi | small | beverage | Default | 162 × 100 |
5119:1707 | hifi | large | beverage | Default | 162 × 153 |
5119:1712 | hifi | small | snack | Default | 162 × 100 |
5119:1717 | hifi | large | snack | Default | 162 × 153 |
5119:1722 | hifi | small | fashion | Default | 162 × 100 |
5119:1727 | hifi | large | fashion | Default | 162 × 153 |
5119:1732 | hifi | small | party | Default | 162 × 100 |
5119:1737 | hifi | large | party | Default | 162 × 153 |
5119:1742 | hifi | small | meal | Default | 162 × 100 |
5119:1747 | hifi | large | meal | Default | 162 × 153 |
5119:1752 | hifi | small | games | Default | 162 × 100 |
5119:1757 | hifi | large | games | Default | 162 × 153 |
5119:1762 | hifi | large | food | horizontal | 336 × 144 |
use case enum and a type=midfi|hifi fidelity axis — both are anti-patterns. Opensize + orientation + image Slot + discount string. Move category artwork to a sibling asset library. Target: 4 variants instead of 20. OpenA 336 × 111 horizontal voucher card with a text content block on the left (title, price, strikethrough original price, validity) and a 96-wide perforated partner-image frame on the right containing the GCash logo and a rotated "GET VOUCHER" CTA label. Ships 4 state variants — limited, expiring, used, expired — plus two boolean toggles (badge, crossedValue). State drives background (active vs greyed), label colors, and the corner badge (information/heavy "Limited" / negative/heavy "Expiring" / muted/light "Used" / muted/light "Expired"). This is the canonical sibling in the voucher-card family — the only one of three parallel components that models state.
5119:1635), and Horizontal Voucher (5121:4533) are three parallel records of the same component. This one carries the canonical state coverage the others lack — state: limited | expiring | used | expired. The consolidation target is a single Voucher Card with orientation: vertical | horizontal × state (5 values if a default is added), text Slots for title / price / originalPrice / validity, a logo Slot for partner branding, and a composable badges array replacing the hardcoded state-to-badge mapping. Push the merge from here, not from the other siblings.4 state variants at 336 × 111. Left content block uses voucher-color tokens for title, amount, and metadata. Right partner-image frame is a raster GCash asset with a perforated edge and a rotated "GET VOUCHER" text label. Badge corner-ribbon color changes per state: information/heavy #2340A9 (Limited), negative/heavy #D61B2C (Expiring), muted/light #C2C5CA (Used / Expired).
main/vouchers/color/default/* and main/vouchers/color/expired/*). Typography uses named text styles (Primary/Multi-line Label/Base, Primary/Label/Small, Secondary/Bold/Small Caption). The card does carry its own layout and state treatment — but the logo raster, the "GET VOUCHER" CTA text, and the validity date are all frozen.state as a variant axis — the other two voucher-card siblings (Vertical Voucher, Horizontal Voucher) do not. But the three components ship as three separate records with divergent property shapes. Family-level inconsistency.| Aspect | iOS | Android | Figma | Notes |
|---|---|---|---|---|
| State | state: .limited | .expiring | .used | .expired | state=VoucherState.Limited | Expiring | Used | Expired | state enum (4) | Drives bg, label colors, partner-image treatment, and corner badge. Add a default fifth state for vouchers without a status callout. |
| Title | title: String | title: String | Hardcoded "Buy Load Globe Go90" | No property; must be set via detach. |
| Price / original | price: String, originalPrice: String? | Same | crossedValue boolean (strings hardcoded) | "PHP 50.00" and "PHP 90.00" frozen; boolean only toggles visibility of the strikethrough. |
| Validity | validity: String? | Same | Hardcoded "Validity: Dec 25 2022 - Jan 5 2023" | No property; must be set via detach. |
| Status badge | badge: EBBadge? | badge: EBBadge? | badge boolean + state-derived text | "Limited" / "Expiring" / "Used" / "Expired" are derived from state. Consumers cannot set their own badge text. |
| Partner logo | logo: Image slot | logo: @Composable () -> Unit | Raster GCash asset | No slot — logo is baked into the partner-image frame. |
| CTA | Entire card as Button with PlainButtonStyle | Card with onClick + ripple | "GET VOUCHER" rotated text (non-interactive) | Card is the tap target; the rotated text is decorative, not a Button instance. |
| Tap state | Pressed: opacity 0.7 or scale 0.98 | Ripple via Modifier.clickable | Not modelled | No pressed/focused/disabled on the card frame. |
- State axis is modelled correctly. Unlike Vertical Voucher and Horizontal Voucher, this component ships a proper
statevariant driving background, label colors, partner-image tint, and badge style. The 4-value enum (limited / expiring / used / expired) is the canonical shape to port to the unified Voucher Card. C5 · Interaction State Coverage - Voucher color tokens are bound. Background (
main/vouchers/color/default/bg,main/vouchers/color/expired/bg), title (default/label-title#0A2757,expired/label-title#445C85), amount (label-amount-horizontal#2340A9,expired/label-amount#6780A9), original amount (label-amount-original#90A8D0), and metadata (expired/label-metadata#6780A9) all resolve through the voucher component variable collection. C3 · Token Coverage - Badge instance is a DS component. The corner ribbon is a real Badge instance — styled via
main/badge/information/heavy/background,main/badge/negative/heavy/background, ormain/badge/muted/light/backgrounddepending on state. Composition works; only the label string is state-derived rather than user-settable. C7 · Code Connect Linkability - Shadow uses the app elevation token.
app/shadow/shadow-low(0 0 4 0 #020e220f) is applied to the card frame. C3 · Token Coverage
- Three parallel components for one concept. Voucher Card Horizontal, Vertical Voucher (
5119:1635), and Horizontal Voucher (5121:4533) are three separate records of the same component. This is a family-level consolidation — the unifiedVoucher Cardneedsorientation: vertical | horizontal×state: default | limited | expiring | used | expired=10 variants, not three divergent symbols. C4 · Native Mappability - All text content is hardcoded. Title "Buy Load Globe Go90", price "PHP 50.00", original price "PHP 90.00", and validity "Validity: Dec 25 2022 - Jan 5 2023" are frozen strings inside every variant. Booleans
badgeandcrossedValueonly toggle visibility — they do not accept content. Consumers cannot render a real voucher without detaching. C2 · Variant & Property Naming - Badge text is state-derived, not independently settable. The corner ribbon label flips between "Limited" / "Expiring" / "Used" / "Expired" based on the
stateenum. A consumer who wants to show "New" or "Featured" on a limited voucher cannot — state and badge label are conflated. Split intostate(drives visual treatment) +badge(independent Slot/string). C2 · Variant & Property Naming - Partner logo is a raster GCash asset with no slot.
imgLogoNoText,imgGCashLogosV2RgbIconBwWhiteTransparent, andimgVoucherImageV1are raster image fills inside the 96×111 partner-image frame. Vouchers for Globe, Smart, GrabFood, or Shopee all render with the GCash logo. No logo Slot exists. C6 · Asset & Icon Quality - Duplicated partner-image subtrees per state.
voucher(used by limited/expiring) andVoucher Image V1(used by used/expired) are two complete parallel subtrees inside the same frame — differing only by background fill (bg/color-bg-primaryvsbg/color-bg-overlay-weak). Should be a single subtree with state-driven tokens. C1 · Layer Structure & Naming - "GET VOUCHER" rotated text is not a Button. The CTA label is a rotated
<p>inside the partner-image frame — not a Button instance, no pressed/focused/disabled state, no onTap handler semantics. If tapping the partner half is supposed to redeem the voucher, that needs to be an actual Button or the whole card needs to be the tap target. C5 · Interaction State Coverage - Perforated ticket edge is a raster mask.
imgPerforateis a raster image used as a mask to produce the perforated dashed edge between the content block and partner frame. Should be a vector path or an SVG mask; at 1× / 2× / 3× the raster will alias. C6 · Asset & Icon Quality - No default (neutral) state. Every variant renders a corner badge. A voucher that is simply available (neither limited nor expiring) has no option to render without a badge beyond setting
badge=false, which drops the callout but keeps the limited-state visual treatment. Add adefaultstate for active-but-unflagged vouchers. C5 · Interaction State Coverage - Two-boolean + 4-enum surface cannot map 1:1 to native. A proper
EBVoucherCard(orientation:, state:, title:, price:, originalPrice:, validity:, logo:, badge:, onTap:)shape has no 1:1 correspondence in the current component — title/price/originalPrice/validity/logo are all hardcoded. Code Connect linkability requires the family consolidation and property-ification first. C7 · Code Connect Linkability
- Merge the three voucher cards into a single Voucher Card component. Vertical Voucher + Horizontal Voucher + Voucher Card Horizontal collapse into one component with
orientation: vertical | horizontal×state: default | limited | expiring | used | expired=10 variants. Port this component's state axis to the unified schema; port Vertical Voucher's content-block structure; drop Horizontal Voucher entirely. Family - Promote every text string to a property. Add
title: String,price: String,originalPrice: String?,validity: String?. Retire thecrossedValueboolean — visibility falls out of whetheroriginalPriceis set. Keep the text-style bindings intact. Property - Split
state(visual treatment) frombadge(label). Keepstateas the 5-value enum that drives bg / label colors / partner-image treatment. Exposebadge: EBBadge?as an independent Slot so consumers can pick any badge style and text ("New", "Featured", "Limited", custom). The current state-to-badge-text mapping becomes the default badge when none is supplied. Property - Adopt a Figma Slot for the partner logo. Replace the raster GCash asset with a 64×64 logo Slot inside the partner-image frame. Consumers instance-swap partner brand marks (Globe, Smart, GrabFood, Shopee, etc.) without detaching. Keep the perforated ticket shape and overlay treatment in the frame itself. Slot
- Collapse the two partner-image subtrees into one.
voucher(limited/expiring) andVoucher Image V1(used/expired) are duplicate layer trees differing only by background token. A single subtree gated by state-driven fills (main/vouchers/color/{state}/partner-bg) removes the duplication. Composition - Add a
defaultstate. Current states are all "flagged" — active-but-unflagged vouchers have no clean render. Addstate: defaultwith the active (non-greyed) treatment and no corner badge by default. State - Replace the perforated raster mask with a vector path. The perforated ticket edge should be an SVG path or a vector mask — not a raster image. Same treatment as recommended for Voucher Asset. Asset
- Make the card the tap target; remove the rotated "GET VOUCHER" text as a faux-button. The entire card is the semantic action ("redeem / open voucher details"). Document the handoff as
onTap; keep "GET VOUCHER" only as a decorative label if product still wants it visible, or drop it entirely. Docs - Add pressed / focused / disabled to the card frame. Vouchers are always tappable — the unified Voucher Card needs a pressed state variant (e.g. opacity 0.8 or scale 0.98), a focused state for keyboard navigation, and a disabled treatment for unavailable vouchers distinct from
expired. State - Rename the duplicated
Badgelayers per state. All four badge layers are namedBadgewith no variant-qualifying name. After the family merge, there should be a singlebadge-slotlayer; until then, name thembadge-limited/badge-expiring/badge-used/badge-expiredfor clarity. Rename
Native models the whole voucher family as a single EBVoucherCard with orientation + state axes, text properties, a logo image slot, and an optional badge overriding the state default. This horizontal card is orientation: .horizontal; its 4 states port 1:1 to the native VoucherState enum.
// Proposed API — unified Voucher Card (horizontal orientation) EBVoucherCard( orientation: .horizontal, state: .limited, title: "Buy Load Globe Go90", price: "PHP 50.00", originalPrice: "PHP 90.00", validity: "Dec 25 2022 - Jan 5 2023" ) { Image("partner-globe") // logo slot .resizable() .scaledToFit() } .onTapGesture { // navigate to voucher details } // Expiring, with strikethrough price EBVoucherCard(orientation: .horizontal, state: .expiring, title: "GrabFood ₱100 OFF", price: "PHP 50.00", originalPrice: "PHP 150.00", validity: "Ends today") { Image("partner-grabfood").resizable().scaledToFit() } // Used / expired — greyed treatment, not tappable EBVoucherCard(orientation: .horizontal, state: .expired, title: "Shopee ₱50 OFF", price: "PHP 20.00", validity: "Expired Apr 1, 2026") { Image("partner-shopee").resizable().scaledToFit() } // Override the state-default badge EBVoucherCard( orientation: .horizontal, state: .default, title: "Tim Hortons Breakfast", price: "PHP 120.00", badge: EBBadge("New", style: .brandHeavy) ) { Image("partner-timhortons").resizable().scaledToFit() }
// Proposed API — unified Voucher Card (horizontal orientation) EBVoucherCard( orientation = EBVoucherOrientation.Horizontal, state = EBVoucherState.Limited, title = "Buy Load Globe Go90", price = "PHP 50.00", originalPrice = "PHP 90.00", validity = "Dec 25 2022 - Jan 5 2023", onClick = { /* navigate to voucher details */ }, logo = { Image( painter = painterResource(R.drawable.partner_globe), contentDescription = null, contentScale = ContentScale.Fit ) } ) // Expiring, with strikethrough price EBVoucherCard( orientation = EBVoucherOrientation.Horizontal, state = EBVoucherState.Expiring, title = "GrabFood ₱100 OFF", price = "PHP 50.00", originalPrice = "PHP 150.00", validity = "Ends today", onClick = { }, logo = { Image(painterResource(R.drawable.partner_grabfood), null) } ) // Override the state-default badge EBVoucherCard( orientation = EBVoucherOrientation.Horizontal, state = EBVoucherState.Default, title = "Tim Hortons Breakfast", price = "PHP 120.00", badge = EBBadge("New", style = EBBadgeStyle.BrandHeavy), onClick = { }, logo = { Image(painterResource(R.drawable.partner_timhortons), null) } )
Current shape is 4 state variants + 2 booleans with hardcoded content. The table below shows the target shape after the family consolidation — each row captures what the proposed EBVoucherCard replaces.
| Current Figma | Proposed Figma | SwiftUI | Compose | Notes |
|---|---|---|---|---|
| — | orientation | orientation: EBVoucherOrientation | orientation=EBVoucherOrientation | vertical | horizontal — collapses 3 components into 1 |
state (4 values) | state (5 values) | state: EBVoucherState | state=EBVoucherState | Add default for active-but-unflagged vouchers |
| hardcoded "Buy Load Globe Go90" | title (string) | title: String | title: String | Property, not frozen |
| hardcoded "PHP 50.00" | price (string) | price: String | price: String | Property, not frozen |
crossedValue (boolean, "PHP 90.00" frozen) | originalPrice (string) | originalPrice: String? | originalPrice: String? | Visibility=whether string is non-nil |
| hardcoded "Validity: …" | validity (string) | validity: String? | validity: String? | Consumer supplies the date range |
| raster GCash logo | logo Slot | trailing closure | logo: @Composable () -> Unit | Partner brand mark, instance-swappable |
badge (boolean, state-derived label) | badge Slot (optional) | badge: EBBadge? | badge: EBBadge? | State drives the default; consumer can override |
| "GET VOUCHER" rotated text | — (remove or decorative) | onTap: () -> Void | onClick: () -> Unit | Card is the tap target |
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Rework | Two parallel partner-image subtrees (voucher vs Voucher Image V1) for active vs greyed states. Four badge layers all named Badge. Content block uses generic container / price names but text layers are unnamed. |
| C2 | Variant & Property Naming | Rework | State enum drives bg + label colors + badge style + badge text all at once. Title, price, original price, validity are frozen strings. crossedValue boolean should be a nullable originalPrice string. |
| C3 | Token Coverage | Ready | All colors bound to main/vouchers/color/{default|expired}/* and main/badge/{information|negative|muted}/{heavy|light}/*. Typography uses named text styles. Shadow uses app/shadow/shadow-low. |
| C4 | Native Mappability | Rework | Parallel to 2 other voucher components with divergent schemas. Native is one EBVoucherCard, not three. Strings/logo need property-ification before any 1:1 mapping. |
| C5 | Interaction State Coverage | Rework | 4 state variants are good but no default, no pressed/focused/disabled on the card frame, and the "GET VOUCHER" CTA is a non-interactive rotated text label rather than a Button. |
| C6 | Asset & Icon Quality | Rework | Partner logo is raster (imgLogoNoText, GCash PNG). Perforated ticket edge uses a raster mask (imgPerforate). Should be vector / SVG path. |
| C7 | Code Connect Linkability | Rework | Cannot map with hardcoded strings and no logo slot. Linkability requires the family consolidation + property-ification first. |
Single axis: state. All 4 variants render at 336 × 111. Booleans badge (default true) and crossedValue (default true) apply uniformly across states.
| Node ID | Variant | Dimensions | Badge style | Partner-image treatment |
|---|---|---|---|---|
5119:1787 | state=limited | 336 × 111 | information/heavy #2340A9, label "Limited" | Full-color bg (bg/color-bg-primary #005CE5) + white GCash logo |
5119:1807 | state=expiring | 336 × 111 | negative/heavy #D61B2C, label "Expiring" | Full-color bg (bg/color-bg-primary #005CE5) + white GCash logo |
5119:1827 | state=used | 336 × 111 | muted/light #C2C5CA, label "Used" | Greyed overlay (bg/color-bg-overlay-weak rgba(2,14,34,0.24)), mix-blend-multiply |
5119:1847 | state=expired | 336 × 111 | muted/light #C2C5CA, label "Expired" | Greyed overlay (bg/color-bg-overlay-weak rgba(2,14,34,0.24)), mix-blend-multiply |
Notable nested nodes (duplicated per variant):
| Node ID | Layer | Kind | Notes |
|---|---|---|---|
5119:1806 | Badge "Limited" | Badge instance | information/heavy style |
5119:1826 | Badge "Expiring" | Badge instance | negative/heavy style |
5119:1846 | Badge "Used" | Badge instance | muted/light style |
5119:1866 | Badge "Expired" | Badge instance | muted/light style |
5119:1799 / 5119:1819 | voucher | Partner-image subtree (active) | Used for limited / expiring — full-color treatment |
5119:1839 / 5119:1859 | Voucher Image V1 | Partner-image subtree (greyed) | Used for used / expired — overlay-weak treatment |
5119:1635), and Horizontal Voucher (5121:4533) into a single Voucher Card with orientation + state axes. Port this component's state coverage to the unified schema. Target: 2 × 5=10 variants instead of 3 divergent components. OpenA 336×704 single-instance symbol that renders the entire voucher-details screen: merchant header (Logo 40px + brand name + "All branches" + a LimitedBadge), voucher content (title + current amount + slashed original amount + validity), a dashed strip divider, a multi-line voucher description, an optional Terms & Conditions plain-text section with a "See full promo mechanics" link, and an optional embedded Terms & Conditions Accordion with 4 check-marked list rows. The component has no variants — only four booleans toggle optional subtrees: accordion, badge, slashedAmount, tCWithTextLink. Every color resolves to main/vouchers/color/default/*, main/badge/*, main/accordion/*, and main/list-item/* tokens from the composed primitives.
EBLogo, EBBadge, EBAccordion, EBListItem) and publish this screen as a recipe in product documentation. The shared "amount + slashed original" row and the dashed strip divider are the only pieces worth extracting — and those belong in a product-layer VoucherAmountRow + TicketDivider, still outside the core DS.One 336×704 symbol. Four boolean toggles show or hide optional subtrees — badge (merchant "Limited" pill), slashedAmount (original price with strikethrough), tCWithTextLink (plain-text T&C section with "See full promo mechanics" link), and accordion (embedded Terms & Conditions Accordion instance, expanded by default with 4 list rows).
| Subtree | DS primitive | Node | Notes |
|---|---|---|---|
| Merchant avatar | Logo 40px | 27:190607 | Instance-swapped for brand identity |
| "Limited" pill | Badge (Information / Heavy) | 21:111526 | Hardcoded "Limited" string |
| Amount row | Custom layer | — | Current amount + slashed original — not a DS primitive |
| Strip divider | Raster image fill | — | Dashed horizontal line rendered as an image — should be a stroke pattern |
| T&C plain text | Custom layer | — | Duplicates the Accordion's expanded body |
| T&C accordion | Terms & Conditions Accordion (itself flagged Remove) | 5119:5447 | Wraps canonical Accordion (16870:9288) with 4 List Item rows |
tCWithTextLink, accordion) render overlapping content (plain-text T&C vs accordion T&C) in parallel. No real voucher turns both on.| Aspect | iOS | Android | Figma | Notes |
|---|---|---|---|---|
| Container | ScrollView with VStack | Column inside verticalScroll | Root frame | Screen-level scroll, not a component |
| Merchant logo | EBLogo(size: .lg) | EBLogo(size=EBLogoSize.Lg) | Instance of Logo 40px | Canonical Logo primitive |
| "Limited" pill | EBBadge("Limited", style: .informationHeavy) | EBBadge("Limited", style=InformationHeavy) | Instance of Badge | String is hardcoded today |
| Amount row | Custom product view | Custom product composable | Local layer | Current amount + slashed original — product-layer concern |
| Strip divider | Canvas stroke pattern | Canvas dash pattern | Raster image fill | Should be a stroke, not a raster asset |
| T&C accordion | EBAccordion(title: "Terms & Conditions") | EBAccordion(title="Terms & Conditions") | Instance | Canonical Accordion with ForEach of EBListItem |
| "See full promo mechanics" | Button(role: .link) | TextButton | Inline colored span | Needs a real link primitive or product-owned LinkText |
- Screen masquerading as a DS component. A 336×704 single-instance symbol with four optional-content booleans is a screen, not a primitive. DS primitives are reusable across many contexts with meaningful variant axes; this exists exactly once and only toggles which optional child renders. C1 · Layer Structure & Naming
- Booleans are child-visibility switches, not states.
accordion,badge,slashedAmount,tCWithTextLinkeach mean "render this optional subtree." They are not semantic props (e.g.hasLimitedOffer,hasOriginalPrice) and not behavioral states. 2^4=16 possible combinations of which only ~4 are legitimate in real product screens. C2 · Variant & Property Naming - Overlapping T&C display paths.
tCWithTextLinkrenders a plain-text Terms & Conditions block with a "See full promo mechanics" link;accordionrenders a full Terms & Conditions Accordion with 4 list rows. Both describe the same information and can be toggled on simultaneously. Real screens pick one. C2 · Variant & Property Naming - Native handoff is a View/Screen, not a Component. SwiftUI would model this as
VoucherDetailsView— a scrollable parent that composesEBLogo,EBBadge, a product-owned amount row,EBAccordion, andEBListItem. There is no value in mapping the screen as a single-symbol Code Connect entry. C4 · Native Mappability - Missing interaction states. Voucher details is interactive on mobile — accordion expands/collapses, the "See full promo mechanics" link navigates, the voucher itself may have a primary "Use Voucher" CTA on the real screen. The symbol ships only one static frame and happens to use
expanded=yesfor the embedded accordion. C5 · Interaction State Coverage - Strip divider is a raster image fill. The dashed horizontal line between voucher content and description is rendered as an imported image (
imgStrip), not astroke-dasharrayor a vector pattern. It will not scale with density, re-color, or adapt to dark mode. C6 · Asset & Icon Quality - Not linkable via Code Connect. A 4-boolean screen symbol cannot map 1:1 to a meaningful native API. Linkability belongs at the primitive level (Logo, Badge, Accordion, ListItem) — each of which already has its own Code Connect track. C7 · Code Connect Linkability
- Retire from the sticker sheet; publish as a product-screen recipe. Move Voucher Details out of the DS file and into a product documentation page titled "Voucher Details Screen". The recipe shows how to compose
EBLogo,EBBadge, the amount row, the strip divider, andEBAccordionwith aForEachofEBListItem. The DS owns primitives; product owns screens. Docs - Extract the shared amount row into a product-layer component. Current amount + slashed original amount is a genuinely reusable product pattern (appears in every voucher variant and on other commerce screens). Lift it into a
VoucherAmountRow(current: "PHP 200.00", original: "PHP 180.00")living in the product library — not the core DS. Composition - Replace the raster strip divider with a stroke pattern. The dashed horizontal rule between voucher content and description is rendered as a raster image. Replace it with a stroke-based divider (
stroke-dasharrayin SVG,Canvason native) so it scales, retints, and adapts to dark mode without a new asset. Asset - Collapse the dual Terms & Conditions paths into one. The symbol ships both a plain-text T&C section and a full accordion T&C section as independent booleans. Pick one pattern for the product — recommendation: keep the accordion (collapsible saves vertical space on small screens) and drop
tCWithTextLinkentirely, moving the "See full promo mechanics" link into the accordion body or next to the CTA. Composition - Promote user-facing strings to properties (if this symbol survives as a product component). If the design team keeps Voucher Details as a product-layer component, hoist "Brand", "All branches", "Voucher Title", "PHP 200.00", "PHP 180.00", validity range, description, and the badge label into named text props so product teams do not detach to change copy. Property
- Native mapping lives at the primitive level. Document that Code Connect mappings for Voucher Details are not added at the screen level. Each composed primitive —
EBLogo,EBBadge,EBAccordion,EBListItem— carries its own mapping. The screen itself ships as a SwiftUIView/ Compose screen-level composable inside product code. Docs
No dedicated EBVoucherDetails component on either platform. The screen is composed in product code from DS primitives plus a product-layer amount row and ticket divider. Example shows the canonical recipe.
// Product-layer screen — lives in app code, not the DS package struct VoucherDetailsView: View { let voucher: Voucher var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { // Merchant header HStack(spacing: 8) { EBLogo(image: voucher.merchantLogo, size: .lg) VStack(alignment: .leading) { Text(voucher.merchant).ebTextStyle(.primaryLabelBase) Text(voucher.locationCopy).ebTextStyle(.secondaryBoldCaption) } Spacer() if let label = voucher.limitedLabel { EBBadge(label, style: .informationHeavy) } }.padding() // Amount block (product-layer component) VoucherAmountRow( title: voucher.title, current: voucher.currentAmount, original: voucher.originalAmount, validity: voucher.validity ) TicketDivider() // product-layer, stroke-based Text(voucher.description) .ebTextStyle(.secondaryBoldBase) .padding() EBAccordion(title: "Terms & Conditions") { VStack(alignment: .leading, spacing: 6) { ForEach(voucher.rules) { rule in EBListItem(rule.text, indicator: .check) } } } } } } }
// Product-layer screen — lives in app code, not the DS package @Composable fun VoucherDetailsScreen(voucher: Voucher) { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { // Merchant header Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp) ) { EBLogo(image = voucher.merchantLogo, size = EBLogoSize.Lg) Column(modifier = Modifier.weight(1f)) { Text(voucher.merchant, style = EBTextStyle.PrimaryLabelBase) Text(voucher.locationCopy, style = EBTextStyle.SecondaryBoldCaption) } voucher.limitedLabel?.let { EBBadge(it, style = EBBadgeStyle.InformationHeavy) } } // Amount block (product-layer composable) VoucherAmountRow( title = voucher.title, current = voucher.currentAmount, original = voucher.originalAmount, validity = voucher.validity ) TicketDivider() // product-layer, stroke-based Text( voucher.description, style = EBTextStyle.SecondaryBoldBase, modifier = Modifier.padding(12.dp) ) EBAccordion(title = "Terms & Conditions") { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { voucher.rules.forEach { rule -> EBListItem(content = rule.text, indicator = EBListIndicator.Check) } } } } }
No 1:1 Code Connect mapping — the screen is not a primitive. Each composed primitive maps via its own component. The current Figma booleans correspond to product-level state on the Voucher model, not to component props.
| Figma boolean | Product model | Native effect | Notes |
|---|---|---|---|
badge | voucher.limitedLabel: String? | Conditional EBBadge render | Drive from data, not a boolean flag |
slashedAmount | voucher.originalAmount: Money? | Conditional strikethrough text | Drive from data — null means no discount |
tCWithTextLink | — | Drop | Overlaps the accordion path; eliminate |
accordion | voucher.rules: [Rule] | EBAccordion + ForEach EBListItem | Always shown when rules exist |
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | N/A | Screen, not a component — layer-naming discipline applies per primitive, not here. |
| C2 | Variant & Property Naming | N/A | Four boolean visibility switches, no variant axis. Not a schema worth normalising. |
| C3 | Token Coverage | Ready | Every color resolves to main/vouchers/*, main/badge/*, main/accordion/*, or main/list-item/* tokens from composed primitives. |
| C4 | Native Mappability | N/A | Maps to a product-layer View/Screen, not a component. No 1:1 DS-to-native handoff. |
| C5 | Interaction State Coverage | N/A | Interaction lives on primitives (Accordion expand/collapse, link tap). Screen-level state is product concern. |
| C6 | Asset & Icon Quality | Rework | Strip divider is a raster image fill — should be a stroke pattern. |
| C7 | Code Connect Linkability | N/A | Not linkable as a unit. Primitives carry their own mappings. |
Single symbol, no variant axes. Four boolean toggles (accordion, badge, slashedAmount, tCWithTextLink) drive optional child subtrees but do not generate variants in the component set.
| Node ID | Dimensions | Default booleans |
|---|---|---|
5119:5368 | 336 × 704 | accordion=true, badge=true, slashedAmount=true, tCWithTextLink=true |
Logo 40px, Badge, Accordion, and List Item. OpenVoucherAmountRow and TicketDivider. Replace the raster strip with a stroke pattern. Drop the tCWithTextLink path to eliminate overlap with the embedded accordion. Open