A live timer showing how long is left. Three layouts — boxed units, a single bar, or a compact pill — each with a running, nearly-over and finished state.
Style is the layout, State is how much time is left, and Surface is what it sits on. A finished state exists for the first time.Adds urgency to anything time-limited — a flash sale, a voucher about to lapse, a payment window closing. Per and One sit inline above a call to action; Pill docks inside a card as a small "ends in" badge.
days / hrs / mins / secs with a colon-separator between each pair.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Style=Per | N/A | N/A | 329 × 50 | One filled box per unit. The most prominent layout. |
| Style=One | N/A | N/A | 237 × 44 | All four units share a single bar. |
| Style=Pill | N/A | N/A | 198 × 30 | Compact badge with a leading icon slot. Uses the inline unit layout. |
| State=Default | N/A | N/A | running | Blue treatment. Plenty of time left. |
| State=Expiring | N/A | N/A | warning | Amber treatment. The threshold is a product decision, not a design one. |
| State=Expired | N/A | N/A | terminal | Recessive grey. The timer has run out and nothing more will change. |
| Surface=onLight | N/A | N/A | bold fill | For placing on a white page — the countdown supplies the colour. |
| Surface=onColor | N/A | N/A | pale fill | For placing on a coloured card — the card supplies the colour. |
- The promo card is its own component. The old set carried a full promo — header copy, a button and a close icon — alongside three timer layouts, as though they were siblings. That card is now Countdown Promo, and what remains here is only the timer. C1 · Layer Structure & Naming
- Each setting does one job.
Stateused to mix how much time was left with which layout was showing, and theStylevaluesWhiteandBluewere colour treatments rather than layouts. NowStyleis the layout,Stateis the time remaining, andSurfaceis the backdrop. C2 · Variant & Property Naming - A finished state exists. There was no way to show a timer that had run out, so screens either kept displaying zeros as though still counting, or swapped the component for something else.
State=Expiredcovers it in all three layouts and on both surfaces. C5 · Interaction State Coverage - Which units show is no longer a variant.
no DaysandMins and Secsencoded the visible unit subset as separate versions, which multiplied the matrix for something that is really data. Both are gone. C2 · Variant & Property Naming - The pill's icon is a slot. It used to embed one fixed, feature-specific glyph. It is now
⤷ LeadingIcon, so any surface can supply its own. C6 · Asset & Icon Quality - All three layouts are assembled the same way. Per named its boxes by unit while One left its instances unnamed and Pill wrapped everything in generic
Time Unitframes with the separator tucked inside each one. Every layout now readsdays/hrs/mins/secswithcolon-separatoras a sibling between them. C1 · Layer Structure & Naming - The backdrop setting says what it means. It was
Theme = Light | Dark, which reads as a system dark mode and would have been wired to one. It describes what the countdown sits on, so it is nowSurface = onLight | onColor— and the values swapped, because the old names described the component's own colour rather than its backdrop. C2 · Variant & Property Naming
- Define the timer's behaviour contract. Figma cannot express the parts that matter most: what interval it ticks on, when it flips to Expiring, what happens at zero, and whether it keeps running in the background. Document those in the Code tab so every consumer behaves the same way. Docs
- Consider making the separator a component. The two-dot
colon-separatoris hand-built and repeated three times per version across all eighteen, with its colour set per state. It works and it is deliberate, but a component would keep the dots from drifting apart over time. Composition
- Announce the time remaining, not each digit. v2.0.1: Applied — Accessibility carries both halves for each platform: the whole timer is exposed as one label rather than four numbers and four labels read separately, and updates go out on a polite interval rather than every second, which would talk over the user. A11y
- Let the native component choose which units to show. v2.0.1: Applied, by a different mechanism than the recommendation proposed. Its premise — "all four units are always drawn, so a two-unit countdown means detaching the instance" — is no longer true: the panel carries
hasDays,hasHours,hasMinutesandhasSeconds, and each switches a unit off in place. The mapping keeps them as four booleans rather than the array the recommendation asked for, so every one traces 1:1 to a Figma property for Code Connect. Property
The columns are the three states, not interaction states — a countdown is never pressed. Each cell gives the onColor value first and the onLight value after the slash where they differ. Values are Per and One; Pill runs its own warning step, text/color-text-warning-stronger, and carries no opacity override. Two colours are set by layer opacity rather than by the token: the unit label is text/color-text-inverse-weak at 80% on the brand surface, and drops to 70% in Expiring and Expired on the pale one. The 80% pairing still falls short of 4.5:1, and that is deliberate for now: it carries over from the Sticker Sheet design and is the next token up that moved the contrast closer without changing the palette. Raising it further is a design recommendation, not a defect.
| Role | Token | Default | Expiring | Expired |
|---|---|---|---|---|
| Surface | bg/color-bg-inverse-weaker · onLight bg/color-bg-brand · warning and info steps by state | #EEF2F9 / #1972F9 | #FCF0CA / #F7D96E | #F6F9FD / #E5F1FF |
| Border | border/color-border-weak · -warning · -disabled | #E5EBF4 / – | #EBB30A | #C2CFE5 |
| #time | text/color-text-primary · -inverse · -warning-strongest on the brand fill, -warning-strong on the pale one · brand-border-primary-disabled | #005CE5 / #FFFFFF | #966F0B / #453408 | #C2CFE5 |
| #unit | text/color-text-primary · -inverse-weak · -warning-strong · brand-border-primary-disabled | #005CE5 / #F6F9FD @ 80% | #966F0B | #C2CFE5 |
| Separator | border/color-border-info · Pill and One lift it on the brand surface · bg/color-bg-warning · brand-border-primary-disabled | #9BC5FD · One #69A6FC, Pill #FFFFFF on brand | #CA970C | #C2CFE5 |
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>.
Nine properties, in the order the Figma property panel lists them. State is the one that is not a parameter: Figma exposes it as a variant because a designer has to pick one to see it, but the platform works it out from the deadline — which is also what the Usage Guidelines ask for. The four unit booleans are independent flags, not a list: switching off hasDays alone is a real combination.
| Figma Property | SwiftUI | Compose |
|---|---|---|
| Style — Per, Pill, One | style: EBCountdownStyle | style: EBCountdownStyle |
| State — Default, Expiring, Expired | not a parameter — computed from until against an expiring threshold | not a parameter — computed the same way |
| Surface — onColor, onLight | surface: EBCountdownSurface = .onColor | surface: EBCountdownSurface = OnColor |
| ⤷ LeadingIcon (slot) | icon: Image? — Pill only | icon: @Composable (() -> Unit)? — Pill only |
| hasSeconds — true, false | hasSeconds: Bool = true | hasSeconds: Boolean = true |
| hasMinutes — true, false | hasMinutes: Bool = true | hasMinutes: Boolean = true |
| hasHours — true, false | hasHours: Bool = true | hasHours: Boolean = true |
| hasDays — true, false | hasDays: Bool = true | hasDays: Boolean = true |
| hasLeadingIcon — true, false | hasLeadingIcon: Bool = true — Pill only | hasLeadingIcon: Boolean = true — Pill only |
EBCountdown( until: saleEnds, style: .per, surface: .onColor )
EBCountdown( until = saleEnds, style = EBCountdownStyle.Per, surface = EBCountdownSurface.OnColor )
EBCountdown( until: saleEnds, style: .pill, surface: .onColor )
EBCountdown( until = saleEnds, style = EBCountdownStyle.Pill, surface = EBCountdownSurface.OnColor )
EBCountdown( until: saleEnds, style: .one, surface: .onColor )
EBCountdown( until = saleEnds, style = EBCountdownStyle.One, surface = EBCountdownSurface.OnColor )
| Requirement | iOS | Android |
|---|---|---|
| The whole timer is one label | .accessibilityElement(children: .ignore) with a formatted duration | Modifier.clearAndSetSemantics { contentDescription = … } |
| Updates are polite, not per-second | Re-announce at meaningful thresholds, not on every tick | LiveRegionMode.Polite on a throttled value |
| Expiring is not signalled by colour alone | Include the urgency in the spoken label | Same, via stateDescription |
| Expired is announced as finished | "Offer ended" rather than a row of zeros | Same |
Do
Derive State from the deadline so it flips to Expiring and Expired on its own.
Don't
Don't set State by hand — a timer that never changes state is worse than no timer.
Do
Pick Surface by what the countdown sits on: onLight for a white page, onColor for a coloured card.
Don't
Don't wire Surface to the system theme — it is about the backdrop, not about dark mode.
Do
Use Pill where space is tight and Per where the urgency is the point.
Don't
Don't put Per inside a card that already has a heading and a button; it will fight them.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | All three layouts read days / hrs / mins / secs with colon-separator siblings. Every unit is an instance. |
| C2 | Variant & Property Naming | Ready | Style, State and Surface each carry one meaning, with semantic values throughout. |
| C3 | Token Coverage | Ready | Every colour is bound and confirmed by design — five roles across three states and two surfaces, including the four warning steps the Expiring state uses. Colour still reaches the units as instance overrides rather than through a property; that is the accepted mechanism, and the Design Recommendations carry the case for driving it by property instead. |
| C4 | Native Mappability | Ready | Maps to a formatted duration view on both platforms. Units are not fixed at four — hasDays, hasHours, hasMinutes and hasSeconds switch each one independently. State is the one property that is not a parameter: the platform computes it from the deadline, which is what the guidelines ask for. |
| C5 | Interaction State Coverage | Not Applicable | Display only — confirmed by design. Default, Expiring and Expired are time states, not interaction states. |
| C6 | Asset & Icon Quality | Ready | The pill's icon is a slot holding a vector instance. No rasters anywhere. |
3 Style × 3 State × 2 Surface = 18 variants, complete with no gaps. The four unit booleans, hasLeadingIcon and the icon slot are content settings and do not multiply the set.
| Style | Size | Unit layout | Count |
|---|---|---|---|
| Per | 329 × 50 | Stacked, one box per unit | 6 |
| Pill | 198 × 30 | Inline, with a leading icon slot | 6 |
| One | 237 × 44 | Stacked, in a shared bar | 6 |
View full Style × State × Surface breakdown (18 rows)
| Style | State | Surface | Size | Node |
|---|---|---|---|---|
| Per | Default | onColor | 329 × 50 | 5630:36049 |
| Per | Default | onLight | 329 × 50 | 5630:36051 |
| Per | Expiring | onColor | 329 × 50 | 5642:37087 |
| Per | Expiring | onLight | 329 × 50 | 5642:37069 |
| Per | Expired | onColor | 329 × 50 | 5656:42337 |
| Per | Expired | onLight | 329 × 50 | 5656:42319 |
| Pill | Default | onColor | 198 × 30 | 5630:36053 |
| Pill | Default | onLight | 198 × 30 | 5630:36054 |
| Pill | Expiring | onColor | 198 × 30 | 5642:37367 |
| Pill | Expiring | onLight | 198 × 30 | 5642:37383 |
| Pill | Expired | onColor | 198 × 30 | 5656:42383 |
| Pill | Expired | onLight | 198 × 30 | 5656:42399 |
| One | Default | onColor | 237 × 44 | 5630:36048 |
| One | Default | onLight | 237 × 44 | 5630:36050 |
| One | Expiring | onColor | 237 × 44 | 5642:36616 |
| One | Expiring | onLight | 237 × 44 | 5642:36876 |
| One | Expired | onColor | 237 × 44 | 5656:42355 |
| One | Expired | onLight | 237 × 44 | 5656:42369 |
#EEF2F9 was recorded as bg/color-bg-secondary on all three cards and in the colours table; it is bg/color-bg-inverse-weaker. The hex was always right — only the name was wrong, which is the worse of the two failures, because a developer implements the name. Caught while reviewing Select Item, whose badge is genuinely bg/color-bg-secondary at #E5F1FF: one token cannot be two values, and comparing the two pages is what surfaced it.Style, State and Surface; the Figma panel also carries hasSeconds, hasMinutes, hasHours, hasDays, hasLeadingIcon and the LeadingIcon slot. Nothing changed in Figma — they were always there. get_node_info cannot read property definitions, so a node-tree reading alone will keep missing them; this is the second component in the family where the property panel held more than the tree could show..eb-preview-cd--onlight and .eb-preview-cd--expiring carry the same specificity, so the state rule won on source order and Surface was ignored whenever State was not Default. Expiring and Expired on the brand surface drew the pale onColor palette — #FCF0CA for #F7D96E, #F6F9FD for #E5F1FF — across all three styles.#9BC5FD, One lifts to #69A6FC and Pill goes white; at Expiring the Pill tracks its own text colour while Per and One use bg/color-bg-warning. The preview applied one rule to all three. Per's is the weakest of the three against its own fill.text/color-text-warning-strongest for the number on the brand surface, -strong for it on the pale one and for the unit label on both, -stronger for the Pill on both, and bg/color-bg-warning for the Per and One separator. The page had a single value.get_node_info returns a fill's own opacity — which is how the 80% unit label on the brand surface was found — but not the layer's. The 70% on the Expiring and Expired unit label only appeared in the Figma Appearance panel, and the preview was resetting it to full strength.Style is the driving property, so Per, Pill and One each get a card — in Figma's panel order, which runs Per, Pill, One rather than the Per, One, Pill the page had. The six content settings are controls on every card, with hasLeadingIcon on Pill alone."Label wording": "days · hrs · mins · secs" sat in the Typography section. The rows now name the styles the Unit instances resolve to: Primary/Headlines/Segment over Primary/Label/Light/Tiny for Per and One, Primary/Label/Light/Base for Pill.Auto across a fixed 198px, not a flat 8px; it resolves to 8 only at full content and would diverge the moment a unit is switched off. All twelve values are now read from the auto-layout panel rather than derived from bounding boxes.font-family: inherit, so it drew in the documentation font rather than Proxima Soft.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.*, matching Countdown - Unit.days / hrs / mins / secs row mapped to units: [TimeUnit] — a list cannot express "days off, hours on", which the four independent booleans can. hasLeadingIcon was absent, and the types read CountdownStyle rather than EBCountdownStyle. Nine rows now, in panel order.State is documented as something the platform works out, not something it is passed. Figma exposes it as a variant because a designer has to pick one to see it; natively it follows from until against an expiring threshold, which is what the Usage Guidelines already asked for. The third shape difference in this family, after hasWeek6 → weeks: Int and UnitLabel → EBTimeUnit.Style now, built from the same definition as the spec cards and the live preview.main/countdown/* namespace that is no longer needed now every colour is bound and confirmed. Both move to Ready, and Code Connect is emptied.4076:9090 in Sticker Sheets v2 is deprecated.State stopped mixing in layout, the White/Blue Style values became Per/One/Pill, and the unit-subset versions were removed.State=Expired added across all three layouts and both surfaces.⤷ LeadingIcon, no longer one fixed feature-specific glyph.days / hrs / mins / secs with colon-separator siblings, replacing three different internal structures.Theme = Light | Dark became Surface = onLight | onColor, with the values swapped, since the old names described the component's own colour rather than its backdrop.