A promotional card built around a countdown — a line of copy, the timer, a full-width action and a dismiss control. Two levels of emphasis.
Runs full-width on the dashboard and on Discover, above the fold, for a campaign with a deadline. High emphasis for a headline promotion, Low for one that sits alongside other cards without shouting.
Container → ⤷ ActionSlot → ⤷ CloseSlot, using the slot naming the rest of the system uses.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Emphasis=High | N/A | N/A | #1972F9 card | Blue card, white copy, and a pale countdown so the timer reads against it. |
| Emphasis=Low | N/A | N/A | #FFFFFF card | White card, dark copy, and a gradient blue countdown to carry the emphasis instead. |
| ⤷ ActionSlot | N/A | N/A | full-width bar | Holds a Button - XSmall stretched edge to edge, which keeps the button's padding and label size. |
| ⤷ CloseSlot | N/A | N/A | 16 × 16 | Dismiss control, top right. |
- The action bar is a Button pushed out of shape. The
⤷ ActionSlotholds a Button - XSmall overridden away from what Button is: 24px tall against Button’s 26, square against its 99px pill, filling 360 where Button hugs, and on the High card painted#2340A9— Button’s pressed fill — as a resting state. A developer handedaction: EBButton?would render a 26px enabled-blue pill, not the full-bleed bar the design shows, so the slot cannot be mapped as it stands. Two ways out, and it needs a decision rather than a fix: either Button gains a full-bleed appearance that this can use, or Promo’s action stops being a Button and becomes its own treatment. C4 · Native Mappability
- The promo is its own component. It used to be a fourth value on Countdown's
Stylesetting, sitting alongside three timer layouts as though a card with copy and a button were the same kind of thing. Splitting it is what let both components get simple. C1 · Layer Structure & Naming - The copy, button and close are no longer baked in. The old card hard-coded its header text, its call to action and its close icon, so any campaign that needed different words meant detaching.
#titleis a text layer, and the other two are⤷ ActionSlotand⤷ CloseSlot. C1 · Layer Structure & Naming - The emphasis setting says what it controls. It was
Theme = Light | Dark, then brieflyColor = Light | Blue. Neither fitted: the card is not following a system theme, and naming a value after its hue is the pattern the guidelines rule out — and the one this very component fixed whenWhiteandBluestopped being Style values. It is nowEmphasis = High | Low, which survives a palette change. C2 · Variant & Property Naming - The dismiss control is visible enough to find. The close icon sat at roughly 1.9:1 against the white card — about half what an interactive control needs. Both versions were re-coloured and now clear 3:1. C3 · Token Coverage
- Slot and wrapper names match the system.
Action SlotandIcon Slotbecame⤷ ActionSlotand⤷ CloseSlot, andcontainerbecameContainer— the same vocabulary as Ad Space, Tooltip and the Date Picker family. C1 · Layer Structure & Naming
- Replace the placeholder copy before handoff.
#titlecurrently holds"Hurry up! ⏎Sale ends in:"— a manual line break and a double space. It is a stand-in while the content model is undecided, but a forced break will not survive translation, so the native side should let the string wrap on its own. Docs - Document that the countdown inside is deliberately inverted. The High card holds a pale timer and the Low card a bold one, so each reads against its own surface. It looks backwards in the layers panel and is worth stating plainly, or someone will "fix" it. Docs
- Give dismissal somewhere to go. The card can be dismissed but nothing says what that means — gone for the session, gone permanently, or gone until the campaign changes. Define it alongside the timer contract so every surface behaves the same way. Docs
- Announce the card as one region. Copy, timer, action and dismiss read separately is four unrelated fragments. Group them so a screen reader announces the promotion, the time remaining and the action together, with the dismiss control reachable but not first. A11y
The five roles Promo paints itself. Everything inside the timer belongs to Countdown and carries its tokens: text/color-text-primary for the numbers and labels, text/color-text-inverse-weak for the label on the brand fill, bg/color-bg-info-weak and border/color-border-info for the separator on the onLight and onColor surfaces. Promo swaps which Countdown surface it places, not those colours.
| Role | Token | High | Low |
|---|---|---|---|
| Card | bg/color-bg-brand · bg/color-bg-main | #1972F9 | #FFFFFF |
| #title | text/color-text-inverse · text/color-text-info-strongest | #FFFFFF | #071969 |
| ActionSlot bar | bg/color-bg-primary-hover · bg/color-bg-primary | #2340A9 | #005CE5 |
| ActionSlot #label | text/color-text-inverse | #FFFFFF | #FFFFFF |
| CloseSlot icon | border/color-border-inverse-weak · border/color-border-stronger | #F6F9FD @ 80% | #7E96BE |
| Countdown inside | placed as Style=One, Surface follows the emphasis | onColor — white on the brand card | onLight — the brand gradient on white |
iOS — Swift Package Manager
.package(url: "https://github.com/AY-Org/eb-ds-ios", from: "1.0.0")
Android — Gradle (Kotlin DSL)
implementation("com.eastblue.ds:countdown:1.0.0")
Import
import EastBlueDS import com.eastblue.ds.countdown.*
Planned API — the native library does not exist yet. The artifact is the Countdown family, not this component: Countdown, Unit and Promo all ship in <code>com.eastblue.ds:countdown</code> and import <code>com.eastblue.ds.countdown.*</code>.
Six properties, in the order the Figma property panel lists them. The deadline is not among them: Figma places a static Countdown instance showing 00:00:00:00, so nothing in the file states when the promotion ends — natively that is until, and the platform derives the timer from it. Each slot keeps its boolean alongside it rather than being inferred from a nil parameter, so every Figma property traces 1:1 for Code Connect.
| Figma Property | SwiftUI | Compose |
|---|---|---|
| Emphasis — High, Low | emphasis: EBCountdownPromoEmphasis | emphasis: EBCountdownPromoEmphasis |
| headerText — free text | headerText: String | headerText: String |
| hasCTA — true, false | hasCTA: Bool = true | hasCTA: Boolean = true |
| hasCloseButton — true, false | hasCloseButton: Bool = true | hasCloseButton: Boolean = true |
| ⤷ ActionSlot (slot) | action: EBButton? — shown when hasCTA | action: @Composable (() -> Unit)? — shown when hasCTA |
| ⤷ CloseSlot (slot) | onDismiss: (() -> Void)? — shown when hasCloseButton | onDismiss: (() -> Unit)? — shown when hasCloseButton |
EBCountdownPromo( headerText: campaign.headline, until: campaign.endsAt, emphasis: .high )
EBCountdownPromo( headerText = campaign.headline, until = campaign.endsAt, emphasis = EBCountdownPromoEmphasis.High )
EBCountdownPromo( headerText: voucher.headline, until: voucher.expiresAt, emphasis: .low, onDismiss: { feed.dismiss(voucher) } )
EBCountdownPromo( headerText = voucher.headline, until = voucher.expiresAt, emphasis = EBCountdownPromoEmphasis.Low, onDismiss = { feed.dismiss(voucher) } )
| Requirement | iOS | Android |
|---|---|---|
| The card is one region | .accessibilityElement(children: .contain) with the title as its label | Modifier.semantics(mergeDescendants = true) |
| Time remaining is spoken, not spelled out | The countdown supplies one formatted duration | Same, via the countdown's contentDescription |
| Dismiss is reachable but not first | Place it last in the accessibility order with a clear label | Same, via traversal order and onClickLabel |
| The action is a button | .accessibilityAddTraits(.isButton) on the bar | Role.Button on the bar |
Do
Let the card pick the countdown treatment that contrasts with it — pale timer on the blue card, bold timer on the white one.
Don't
Don't match the timer to the card; it will disappear into its own background.
Do
Use High for one headline promotion per surface.
Don't
Don't stack two High cards — competing urgency reads as neither.
Do
Give the dismiss control a defined lifetime.
Don't
Don't ship a close button that brings the card back on the next launch.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Container → ⤷ ActionSlot → ⤷ CloseSlot in both versions, matching the system's slot vocabulary. |
| C2 | Variant & Property Naming | Ready | Emphasis = High | Low — semantic, and survives a palette change. |
| C3 | Token Coverage | Ready | Card, copy, action and dismiss all bound. The close icon now clears 3:1 on both versions. |
| C4 | Native Mappability | Needs Refinement | Five parameters, no web-only pattern — but the ⤷ ActionSlot does not map as it stands. It holds a Button - XSmall overridden away from what Button is: 24px tall against Button’s 26, square against its 99px pill, filling 360 against its hug, and on the High card painted #2340A9 — Button’s pressed fill — as a resting state. A developer given action: EBButton? would render a 26px enabled-blue pill, not the full-bleed bar the design shows. Either Button gains a full-bleed appearance or Promo’s action becomes its own treatment; the slot maps once that is decided. |
| C5 | Interaction State Coverage | Not Applicable | The card has no states of its own — pressed behaviour belongs to the button and the dismiss control. |
| C6 | Asset & Icon Quality | Ready | The close icon is a vector instance in a slot. |
1 Emphasis setting × 2 values. The countdown inside always runs opposite to the card.
| Emphasis | Card | Countdown inside | Node |
|---|---|---|---|
| High | #1972F9 | Style=One, Surface=onColor | 5630:36045 |
| Low | #FFFFFF | Style=One, Surface=onLight | 5630:36046 |
⤷ ActionSlot holds a Button - XSmall overridden away from what Button is — 24px tall against 26, square against the 99px pill, filling 360 where Button hugs, and painted with Button’s pressed fill as a resting state. It needs a decision rather than a fix: either Button gains a full-bleed appearance, or Promo’s action stops being a Button.Emphasis; the Figma panel also carries headerText, hasCTA and hasCloseButton. Nothing changed in Figma — they were always there. get_node_info cannot read property definitions, so this is the third component in the family where the panel held more than the node tree could show.Emphasis value. High and Low, in the panel’s order, with the three content properties as controls on both rather than as extra cards.Auto, radius 0. Four of the old values were labelled "(derived)" and three of those were wrong.Style=One cannot express a narrow timer on its own.#title renders its line break again. Figma sets the placeholder as Hurry up! then a break then Sale ends in:. A text input cannot hold a newline, so the control shows the flattened string and the break is restored while it is untouched.</div> in livePreviewHtml closed [data-tab-group] two levels early, which left the Style, Code and Changelog panels outside the root switchTab queries — clicking a tab deactivated Overview and had no panel to activate. The markup and the previews were correct throughout; only the nesting was wrong, and both the build and the lint passed. Measured across all 95 components afterwards: no other page is affected.gcash/east-blue-ios and com.gcash.eastblue:components:1.0.0, with no Import line. It now cites the Countdown family artifact com.eastblue.ds:countdown:1.0.0 and imports com.eastblue.ds.countdown.* — the third and last member of the family onto the family artifact.hasCTA and hasCloseButton had no rows; #title named the layer rather than the headerText property; and a Countdown row mapped something that is not a property at all. Six rows now, one per property. The deadline is named in the description instead: Figma places a static instance showing 00:00:00:00, so nothing in the file says when a promotion ends.title: and emphasis = High where the Style tab’s DEV code calls headerText: and EBCountdownPromoEmphasis.Low. Both now use one API, and the two snippets are keyed to the Emphasis values rather than to placement.getSnippet interpolated the placeholder’s real line break straight into the Swift literal, so switching language tabs printed a string broken across two lines. It escapes to \n now, the way a developer would have to write it.⤷ ActionSlot takes a Button - XSmall overridden four ways — 24px tall against Button’s 26, square against its 99px pill, filling 360 against its hug, and on the High card painted #2340A9, which is Button’s pressed fill, as a resting state. A developer given action: EBButton? would render a 26px enabled-blue pill, not the full-bleed bar the design shows. Native Readiness moves from Ready to Needs Refinement; the Overview issue and the fix are a separate pass.#title is a text layer; the button and close icon are slots.Theme became Emphasis = High | Low, after a pass through Color = Light | Blue, which repeated the hue-as-value pattern the guidelines rule out.⤷ ActionSlot, ⤷ CloseSlot, Container.#title still holds placeholder copy with a manual line break, kept while the content model is undecided.