A live timer showing time remaining (days · hours · minutes · seconds) across multiple presentations — full promo card, single bar, segmented boxes, or a compact pill.
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.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.
style values on a smaller primitive would actually be reusable.Task Delayed Small icon (a feature-specific glyph) rather than exposing an icon slot.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.#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.| 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. |
- 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
-
Stateaxis 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 | pillandstate = default | expiring. C2 · Variant & Property Naming -
Mode = Light | Darkbaked as a variant instead of theme. The Mode axis renders different bg / text colors per theme — exactly what design tokens + a singlethememode 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 Secsvariants 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 | Blueon the Pill is a theme/color choice. It overlaps withMode = 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
#titletext "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/@Composableslots 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 Smallicon 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
- Split Full Container out of the component into a recipe. Publish
EBCountdownPromoas a documented composition (header + EBCountdown + Button + close icon) on the Countdown page. KeepEBCountdownthe 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],themeinherited. 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 DaysandMins and Secssub-variants. Replace with aunitsarray 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
trailingslot (close icon / icon-button / empty) and afooterslot (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?: Iconslot. 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
onExpirecallback; 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
accessibilityLabelcontract like "Sale ends in 5 days 9 hours". Mark the close button as a separate accessibility element. A11y
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.
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 · Default | Light · Expiring | Dark · Default | Dark · 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 |
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.
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 · Default | Light · Expiring | Dark · Default | Dark · 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 |
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).
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.
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 Property | SwiftUI | Compose |
|---|---|---|
| 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) |
// 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() } )
| Requirement | iOS | Android |
|---|---|---|
| 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. |
| 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. |
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.
| # | Style | State | Mode | Variant | Size | Node |
|---|---|---|---|---|---|---|
| 1 | Full Container | Default | Light | Default | 360 × 92 | 4076:9091 |
| 2 | Full Container | Expiring | Light | Default | 360 × 92 | 4076:9118 |
| 3 | Full Container | Default | Dark | Default | 360 × 92 | 4076:9145 |
| 4 | Full Container | Expiring | Dark | Default | 360 × 92 | 4076:9172 |
| 5 | One Container | Default | Light | Default | 360 × 50 | 4076:9199 |
| 6 | One Container | Expiring | Light | Default | 360 × 50 | 4076:9221 |
| 7 | One Container | Default | Dark | Default | 360 × 50 | 4076:9243 |
| 8 | One Container | Expiring | Dark | Default | 360 × 50 | 4076:9265 |
| 9 | Per Container | Default | Light | Default | 360 × 50 | 4076:9287 |
| 10 | Per Container | Expiring | Light | Default | 360 × 50 | 4076:9309 |
| 11 | Per Container | Default | Dark | Default | 360 × 52 | 4076:9331 |
| 12 | Per Container | Expiring | Dark | Default | 360 × 52 | 4076:9353 |
| 13 | Per Container | Default | Light | no Days | 238 × 50 | 4076:9375 |
| 14 | Per Container | Expiring | Light | no Days | 238 × 50 | 4076:9391 |
| 15 | Per Container | Default | Dark | no Days | 238 × 52 | 4076:9407 |
| 16 | Per Container | Expiring | Dark | no Days | 238 × 52 | 4076:9423 |
| 17 | Per Container | Default | Light | Mins & Secs | 147 × 50 | 4076:9439 |
| 18 | Per Container | Expiring | Light | Mins & Secs | 147 × 50 | 4076:9449 |
| 19 | Per Container | Default | Dark | Mins & Secs | 147 × 52 | 4076:9459 |
| 20 | Per Container | Expiring | Dark | Mins & Secs | 147 × 52 | 4076:9469 |
| 21 | Pill · White | Default | Light | — | 161 × 29 | 4076:9479 |
| 22 | Pill · White | Expiring | Light | — | 161 × 29 | 4076:9482 |
| 23 | Pill · Blue | Default | Dark | — | 161 × 29 | 4076:9485 |
| 24 | Pill · Blue | Expiring | Dark | — | 161 × 29 | 4076:9488 |
Style × State × Mode × Variant into prop-driven style + state + units + theme-inherited mode. OpenState = Default | Expiring | Pill mixes state with layout; Style = White | Blue overlaps with Mode. Needs renaming + splitting. Opencountdown/* token scope registered. OpenTask Delayed Small rather than exposing a leading-icon slot. Open