RestructureRequires Rework
Countdown Component link

A live timer showing time remaining (days · hours · minutes · seconds) across multiple presentations — full promo card, single bar, segmented boxes, or a compact pill.

Split into a primitive + recipes; collapse the State/Style/Mode/Variant matrix
Today's Countdown bundles four very different presentations (Full promo card, One bar, Per-unit boxes, Pill) into one COMPONENT_SET with ~24 variants. The promo "Full Container" is a composition (header + countdown row + Button + close icon), not a primitive — it should live as a recipe over a small Countdown timer primitive that ships only the time-unit display. State (Default / Expiring) is purely a color override and should be a prop, not a variant. Mode = Light/Dark should follow the theme, not be a baked variant. The no Days / Mins and Secs variants encode which units are visible and should be data-driven (units: [.days, .hours, .mins, .secs] or similar). Recommendation: ship one EBCountdown primitive with style, state, units, and theme props; publish the "Full promo" as a composition example, not a sibling.
In Context

Used to add urgency around time-limited offers — flash sales, voucher expiry, limited-time deals on the Discover and Voucher surfaces. The Pill variant docks inside cards as a small "ends in" badge; the Full Container is the standalone promo bar; One / Per Container sit inline above CTAs.

Sale ends in 5 : 9 : 48 : 16 03d : 37h : 01m Show now!
Live Preview
Properties
Style
State
Mode
DS Health
Reusable
Warn
Covers four real product needs (promo card, inline bar, segmented boxes, pill badge) — but bundling them as one component forces consumers to discover them by trial. Splitting Full Container into a recipe and shipping Pill / One / Per as style values on a smaller primitive would actually be reusable.
Self-contained
Partial
Full Container instance-swaps Button - XSmall and a Close icon — both are external dependencies that drift independently. The Pill variant embeds the Task Delayed Small icon (a feature-specific glyph) rather than exposing an icon slot.
Consistent
Fail
Axis names collide: State = Default | Expiring | Pill mixes a true state ("Expiring") with a layout-style ("Pill"). Style = Full Container | One Container | Per Container | White | Blue conflates layout (Full/One/Per) with color treatment (White/Blue) — those last two should be theme tokens, not style values.
Composable
Warn
No content slots. The title text in Full Container is a baked #title text node ("Hurry up! Sale ends in:"), not a slot. The CTA is a hard-baked Button - XSmall with the literal label "Show now!". Consumers can't change copy without detaching.
Behavior
State iOS Android Figma Property Notes
Ticking Yes Yes Not modeled Component is a static visual snapshot — no spec for the 1s tick, monotonic-clock source, or text update strategy. Native dev has to invent both.
Reaches 0 N/A N/A Not modeled No "expired" terminal state. Should the component switch to a static "Expired" label, hide itself, or fire a callback?
Expiring threshold N/A N/A Color-only State = Expiring is described as a visual style. The threshold (when to flip from Default to Expiring — last 24h? 1h?) isn't spec'd.
Pill close affordance N/A N/A na Pill has no close icon, but Full Container does. Whether the X dismisses the promo (and what dismissal means — hide forever / session-only) is undocumented.
A11y — live region N/A N/A Not modeled A ticking timer should be a polite live region with a stable announcement cadence (every minute, not every second). No annotation.
Open Issues
  • Four different layouts bundled as one component. Full Container is a promo card (header + countdown + CTA + close), One/Per Container are bare countdown bars, and Pill is a compact badge. These have different consumers, different paddings, different content contracts — they should be a primitive (the time-unit display) plus a composition recipe (the promo card). C1 · Layer Structure & Naming
  • State axis mixes state + layout. State = Default | Expiring | Pill. "Expiring" is a true state (an urgency cue near the deadline). "Pill" is a layout treatment. They should be separate properties: style = full | one | per | pill and state = default | expiring. C2 · Variant & Property Naming
  • Mode = Light | Dark baked as a variant instead of theme. The Mode axis renders different bg / text colors per theme — exactly what design tokens + a single theme mode are for. Baking Mode as a structural variant duplicates the variant matrix and forces consumers to manually switch component instances on theme change. C3 · Token Coverage
  • no Days / Mins and Secs variants encode visible-unit subset. These two Per Container sub-variants only differ in which time units are visible (h:m:s vs m:s). That's a data concern (units: [.hours, .minutes, .seconds]), not a variant axis. Encoded as variants, every future "Y:H:M:S" or "M:S only" need produces yet another variant. C2 · Variant & Property Naming
  • "White" / "Blue" Style values are color treatments, not layouts. Style = White | Blue on the Pill is a theme/color choice. It overlaps with Mode = Light | Dark — there's no defined matrix for "White-style Dark-mode". Color treatments belong in tokens; style should describe layout only. C2 · Variant & Property Naming
  • Full Container hard-codes header text + CTA + close. The #title text "Hurry up! Sale ends in:" and the CTA button labeled "Show now!" are literal strings inside the component. Consumers using this for an offer without a CTA, or with a different message, have to detach. C4 · Native Mappability
  • Close icon, CTA, and time-unit labels are not slots. Close icon is an instance with no opt-out, the CTA is a baked Button - XSmall instance, and the "days / hrs / mins / secs" labels are literal text — none of which is a Figma Slot. Native devs can't map these to @ViewBuilder / @Composable slots without a Figma restructure. C4 · Native Mappability
  • No "expired" terminal state. Components like this need a defined behaviour when the timer hits 0:00:00:00 — either swap to an "Expired" label, fire a callback, or hide. None of those are spec'd in any variant. C5 · Interaction State Coverage
  • No pressed / disabled / dismissed states. The promo CTA and the close icon are tappable but lack pressed / disabled spec. Pill should also have a disabled (greyed) state for promo-ended cases. None are modelled. C5 · Interaction State Coverage
  • Pill embeds a feature-specific icon. The leading Task Delayed Small icon is baked into every Pill variant. Other countdown contexts may want a clock, hourglass, calendar, or no icon at all. Should be an optional leading-icon slot, not a baked instance. C6 · Asset & Icon Quality
  • Code Connect mappings not registered. Blocked on the restructure decision — once Style / State / Theme are split correctly, native mappings collapse from ~24 variants × props down to ~6 props with a single composable. C7 · Code Connect Linkability
Design Recommendations
  • Split Full Container out of the component into a recipe. Publish EBCountdownPromo as a documented composition (header + EBCountdown + Button + close icon) on the Countdown page. Keep EBCountdown the primitive for just the time-unit display. Removes a whole instance-swap dependency on Button + Close and decouples promo-copy decisions from the timer. Family
  • Collapse Style + State + Mode + Variant into a small prop set. Target API: style: .one | .per | .pill, state: .default | .expiring | .expired, units: [.days, .hours, .mins, .secs], theme inherited. That is 3 + 3 + N + theme → an order of magnitude fewer variants in Figma and 1:1 mapping to a native component. Property
  • Drive units by data, not by variants. Remove the no Days and Mins and Secs sub-variants. Replace with a units array on the primitive that controls which unit cells render. Same Figma component supports any subset without producing new variants. Property
  • Move "Expiring" colors to state tokens. Today the Default vs Expiring palette is hardcoded per variant. Define countdown/color/{default|expiring}/{bg|border|label|number} token roles and bind both states' colors. State becomes a single prop that drives token swaps instead of a variant axis. Token
  • Replace baked CTA and close instance with slots. In the Full Container recipe, surface a trailing slot (close icon / icon-button / empty) and a footer slot (CTA composition). Removes the hard dependency on Button - XSmall and lets consumers compose the promo to their need. Slot
  • Add an optional leading-icon slot on Pill. Replace the baked Task Delayed icon with an icon?: Icon slot. Default to no icon. Pill becomes generic enough for any time-limited surface (offer expiry, scheduled job, queue position). Slot
  • Define the timer behaviour contract. Document on the component: 1s tick cadence; monotonic clock (no drift on backgrounding); polite live-region announce on minute boundary; an onExpire callback; and the Expiring threshold (recommend last 24h with a token-driven hour count). Without this, every native implementation reinvents these details. Docs
  • Document the A11y model. For ticking timers: VoiceOver / TalkBack should not announce every second. Use a polite live region that announces once per minute (or when crossing Expiring threshold). Provide an accessibilityLabel contract like "Sale ends in 5 days 9 hours". Mark the close button as a separate accessibility element. A11y
Types
Full Container
DES DEV

Promo card. Header copy ("Hurry up! Sale ends in:"), a One-Container countdown row, a trailing close icon, and a Button - XSmall CTA. Flip Mode for light / dark surfaces and State for the urgency palette.

Properties
State
Mode
Properties
State default
Mode light
Colors
Card bg #FFFFFF
Header label #071969
Countdown row bg #EEF2F9
Countdown numbers #2340A9
CTA bg #005CE5
Close icon #0A2757
Layout
Card size 360 × 92
Top section 360 × 68
Title 95 × 32 at (20, 18)
Inline countdown 185 × 44 at (151, 12)
Inline padding 4 / 6 vert · 8 horiz
Inline gap 8
CTA 360 × 24 (full bleed)
Close icon 16 × 16 (top-right, inset 4)
Typography
Header Proxima Soft Bold · 16 / 16 · +0.25
Number Proxima Soft Bold · 20 / 24 · 0
Unit Proxima Soft Semibold · 10 / 10 · +0.25
CTA Proxima Soft Bold · 14 / 14 · +0.25
Container fills — by Mode × State

Each Style + State + Mode combo uses its own bg + border pair. Dark mode uses solid fills (no border); Light mode is a tinted bg + matching border.

Role Token Light · DefaultLight · ExpiringDark · DefaultDark · Expiring
One Container bg countdown/one/{mode}/{state}/bg #EEF2F9 #FCF0CA gradient #1972F9 → #005CE5 #F7D96E
Per Container bg countdown/per/{mode}/{state}/bg #EEF2F9 #FCF0CA #1972F9 #F7D96E
Pill bg countdown/pill/{mode}/{state}/bg #EEF2F9 #FCF0CA #1972F9 #F7D96E
Light bg border countdown/{style}/light/{state}/border #E5EBF4 #EBB30A none none
One Container
DES DEV

Single bar with four time units inline (days · hrs · mins · secs). Common as a strip under banners and above CTAs. Flip Mode for surface theme, State for the urgency palette.

Properties
State
Mode
Properties
State default
Mode light
Colors
Bg #EEF2F9
Border #E5EBF4
Number #2340A9
Unit label #6075C1
Layout
Size 360 × 50
Padding 8 vert · 16 horiz
Border radius 6
Cell 28 × 34
Colon 3 × 10 (two 3×3 dots, 4 gap)
Gap space-between (~34)
Typography
Number Proxima Soft Bold · 20 / 24 · 0
Unit Proxima Soft Semibold · 10 / 10 · +0.25
Text & icon — by Mode × State

Number is Proxima Soft Bold 20/24; label is Semibold 10/10. Colons are 3×3 dots in pastel blue on Light Default, amber on Expiring.

Role Token Light · DefaultLight · ExpiringDark · DefaultDark · Expiring
Number countdown/{style}/{mode}/{state}/number #2340A9 #6C5009 #FFFFFF #453408
Unit label countdown/{style}/{mode}/{state}/unit #6075C1 #6C5009 #FFFFFF #453408
Colon dots countdown/{style}/{mode}/{state}/colon #9BC5FD #EBB30A #9BC5FD #453408
Pill text countdown/pill/{mode}/{state}/text #2340A9 #6C5009 #FFFFFF #453408
Pill icon countdown/pill/{mode}/{state}/icon #2340A9 #6C5009 #FFFFFF #453408
Per Container
DES DEV

Each time unit lives in its own 56×50 box, separated by colon glyphs. Use Variant to drop higher-order units (no Days, or Mins-and-Secs only).

Properties
State
Mode
Variant
Properties
State default
Mode light
Variant default
Colors
Cell bg #EEF2F9
Cell border #E5EBF4
Number #2340A9
Unit label #6075C1
Layout
Row width 360
Row height 50
Cell 56 × 50
Cell radius 8
Colon 3 × 10 (two 3×3 dots, 4 gap)
Gap space-between (~21)
Typography
Number Proxima Soft Bold · 20 / 24 · 0
Unit Proxima Soft Semibold · 10 / 10 · +0.25
Pill
DES DEV

Compact 161×29 badge with a leading clock glyph and an inline "03d : 37h : 01m" string. Use inside cards and rows to communicate time-remaining without the visual weight of a full bar.

Properties
State
Mode
Properties
State default
Mode light
Colors
Pill bg #EEF2F9
Pill border #E5EBF4
Text #2340A9
Icon #2340A9
Layout
Size 161 × 29
Padding 0 left 8 · right 12 · vertical 4
Border radius 44 (pill)
Icon size 20 × 20 at (8, 4)
Icon→text gap 8
Typography
Text Proxima Soft Semibold · 16 / 16 · +0.25
Property Mapping

The current Figma schema (~24 variants on State × Style × Mode × Variant) does not map 1:1 to a single native primitive. The table below maps the proposed post-restructure schema — one <code>EBCountdown</code> primitive with prop-driven style/state, units, and theme inherited.

Figma PropertySwiftUICompose
Style = One / Per / Pill style: EBCountdownStyle style: EBCountdownStyle
State = Default / Expiring state: EBCountdownState state: EBCountdownState
Mode = Light / Dark (inherited from theme) (inherited from theme)
Variant = Default / no Days / Mins and Secs units: [EBTimeUnit] units: List<EBTimeUnit>
(implicit end date) endsAt: Date endsAt: Instant
(implicit tick) (internal Timer; 1s polite live region announce) (internal Flow; 1s polite live region announce)
(no expired terminal) onExpire: () -> Void onExpire: () -> Unit
Full Container = recipe EBCountdownPromo (composition) EBCountdownPromo (composition)
SwiftUI
ios/Components/Countdown/EBCountdown.swift
Jetpack Compose
android/components/countdown/EBCountdown.kt
Usage Snippets Planned API
Usage
// Primitive — inline countdown bar
EBCountdown(endsAt: saleEnd)
    .ebStyle(.one)

// Segmented boxes, Mins+Secs only
EBCountdown(endsAt: lastChance)
    .ebStyle(.per)
    .ebUnits([.mins, .secs])

// Pill — dock inside a Voucher card
EBCountdown(endsAt: voucherExpiry)
    .ebStyle(.pill)
    .ebState(timeLeft < .hours(24) ? .expiring : .default)

// Promo recipe — composition over the primitive
EBCountdownPromo(
    title: "Hurry up! Sale ends in:",
    endsAt: saleEnd,
    cta: "Show now!",
    onTapCTA: { openSale() }
)
// Primitive — inline countdown bar
EBCountdown(endsAt = saleEnd, style = EBCountdownStyle.One)

// Segmented boxes, Mins+Secs only
EBCountdown(
    endsAt = lastChance,
    style = EBCountdownStyle.Per,
    units = listOf(EBTimeUnit.Min, EBTimeUnit.Sec)
)

// Pill — dock inside a Voucher card
EBCountdown(
    endsAt = voucherExpiry,
    style = EBCountdownStyle.Pill,
    state = if (timeLeft < 24.hours) EBCountdownState.Expiring else EBCountdownState.Default
)

// Promo recipe — composition over the primitive
EBCountdownPromo(
    title = "Hurry up! Sale ends in:",
    endsAt = saleEnd,
    cta = "Show now!",
    onTapCTA = { openSale() }
)
Accessibility
RequirementiOSAndroid
Live region Mark the countdown as .accessibilityElement(children: .combine) + polite live region. Announce once per minute (or when crossing the Expiring threshold), not every second. Wrap in Modifier.semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite }; debounce announcements to once per minute.
Spoken announcement Use a friendly relative phrase: "Sale ends in 5 days 9 hours". Avoid reading colon-separated digits. Set contentDescription = "Sale ends in 5 days 9 hours". Localize unit words via resource strings.
Expired state When the timer reaches 0, swap to an Expired label and announce "Sale ended" once. Stop updating the live region. Same — swap label, announce once via announceForAccessibility, then stop.
Promo close button In the Full Container recipe, the close icon is a separate focusable element with .accessibilityLabel("Dismiss promo"). Close icon uses contentDescription = "Dismiss promo" and is its own focusable element.
Reduced motion Honour UIAccessibility.isReduceMotionEnabled — if true, suppress any unit-flip animations (none in v1 anyway). Honour Settings.Global.ANIMATOR_DURATION_SCALE = 0 — suppress unit-flip animation.
Criteria Scorecard
ID Criterion Status Notes
C1 Layer Structure & Naming Requires Rework Four distinct presentations bundled as one component. Full Container is a composition, not a primitive.
C2 Variant & Property Naming Requires Rework State mixes state and layout; Style mixes layout and color treatment; Variant encodes a data subset that should be a units array.
C3 Token Coverage Needs Refinement Hex values are consistent across variants but token namespace not registered. Need countdown/{style}/{mode}/{state}/* token scope.
C4 Native Mappability Requires Rework Full Container hard-codes header text + CTA + close instance. Post-restructure mapping is clean.
C5 Interaction State Coverage Requires Rework No expired terminal, no pressed/disabled on CTA + close, no Expiring threshold spec.
C6 Asset & Icon Quality Needs Refinement Pill embeds a feature-specific Task Delayed icon. Should be an optional icon slot.
C7 Code Connect Linkability Not Mapped Blocked until restructure decision lands.
Variants Inventory (24 total)

Sticker sheet ships 24 variants across State × Style × Mode × Variant. State = Default + Expiring; Style = Full Container, One Container, Per Container, Pill (with White/Blue color treatments); Mode = Light / Dark; Variant (Per Container only) = Default / no Days / Mins and Secs.

#StyleStateModeVariantSizeNode
1Full ContainerDefaultLightDefault360 × 924076:9091
2Full ContainerExpiringLightDefault360 × 924076:9118
3Full ContainerDefaultDarkDefault360 × 924076:9145
4Full ContainerExpiringDarkDefault360 × 924076:9172
5One ContainerDefaultLightDefault360 × 504076:9199
6One ContainerExpiringLightDefault360 × 504076:9221
7One ContainerDefaultDarkDefault360 × 504076:9243
8One ContainerExpiringDarkDefault360 × 504076:9265
9Per ContainerDefaultLightDefault360 × 504076:9287
10Per ContainerExpiringLightDefault360 × 504076:9309
11Per ContainerDefaultDarkDefault360 × 524076:9331
12Per ContainerExpiringDarkDefault360 × 524076:9353
13Per ContainerDefaultLightno Days238 × 504076:9375
14Per ContainerExpiringLightno Days238 × 504076:9391
15Per ContainerDefaultDarkno Days238 × 524076:9407
16Per ContainerExpiringDarkno Days238 × 524076:9423
17Per ContainerDefaultLightMins & Secs147 × 504076:9439
18Per ContainerExpiringLightMins & Secs147 × 504076:9449
19Per ContainerDefaultDarkMins & Secs147 × 524076:9459
20Per ContainerExpiringDarkMins & Secs147 × 524076:9469
21Pill · WhiteDefaultLight161 × 294076:9479
22Pill · WhiteExpiringLight161 × 294076:9482
23Pill · BlueDefaultDark161 × 294076:9485
24Pill · BlueExpiringDark161 × 294076:9488
1.0.0 — 2026-05-18Major
Initial Assessment · node 4076:9090
Family assessed — 24 variants across 4 Styles × 2 States × 2 Modes × 3 Variants (Per Container only). Discover and Voucher surfaces consume Pill; promo bars on flash-sale screens use Full Container. Documented
Initial
Verdict: Restructure — Split Full Container into a composition recipe; collapse Style × State × Mode × Variant into prop-driven style + state + units + theme-inherited mode. Open
Family
C1 — Component scope — Four very different presentations bundled as one component. Full Container is a composition, not a primitive. Open
C1
C2 — Axis collisionsState = Default | Expiring | Pill mixes state with layout; Style = White | Blue overlaps with Mode. Needs renaming + splitting. Open
C2
C3 — Token namespace — Hex values consistent but no countdown/* token scope registered. Open
C3
C4 — Hard-coded content — Full Container bakes "Hurry up! Sale ends in:" + "Show now!" + Button - XSmall + Close. None are slots. Open
C4
C5 — Missing states — No expired terminal, no pressed/disabled on CTA, no Expiring threshold definition. Open
C5
C6 — Embedded feature icon — Pill embeds Task Delayed Small rather than exposing a leading-icon slot. Open
C6
C7 — Code Connect — Not registered. Blocked on restructure. Open
C7