The voucher card, in the two shapes the app uses — a portrait tile for grids and a landscape row for lists — across the three states a voucher can be in.
Voucher Card Horizontal, Horizontal Voucher, Vertical Voucher and Voucher Asset are gone; all four are now settings on this one component. AssetSize, Orientation and State each carry one meaning, and the artwork, status badge and discount badge are real slots instead of baked artwork with frozen strings. The two orientations are laid out differently on purpose — they are not the same card rotated.Vertical fills the voucher grid on Discover and in campaign carousels, where cards sit side by side. Horizontal fills the list on My Vouchers, where each row needs room for a description and its own claim action.
Content → Details, Price Container, and ⤷ AssetSlot / ⤷ BadgeSlot for the swappable parts. Text layers are #title, #description, #price, #originalPrice and #validity in both.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Orientation=Vertical | N/A | N/A | 162 wide | Artwork on top, then badge, title, description, price, validity. Carries the discount badge. |
| Orientation=Horizontal | N/A | N/A | 312 / 344 wide | Text on the left, artwork on the right with a fixed claim rail. No discount badge — there is no room for it. |
| AssetSize=Small | N/A | N/A | 162 × 278 · 312 × 124 | The default for both orientations. |
| AssetSize=Large | N/A | N/A | 162 × 341 · 344 × 124 | Taller artwork on vertical; a wider card on horizontal. |
| State=Default | N/A | N/A | claimable | Full colour. The badge reads the offer status, e.g. "Limited". |
| State=Used | N/A | N/A | dimmed | A #020E22 overlay at 56% darkens the artwork; the card stays legible. |
| State=Expired | N/A | N/A | washed out | A flat #C2CFE5 overlay replaces the artwork and the text recedes. |
- Both dimmed states are built from tokens now. A grey with no variable behind it was showing up in the file’s colour list. It was the badges and the claim rail in the two dimmed states — not the placeholder artwork they were first pinned on, which is a bound
bg/color-bgrectangle under a raster image. All of them now stackbg/color-bg-mainunder an overlay, with only the overlay weight separating the states: 40% for Used, 24% for Expired. Used lands back on#9A9FA7, the colour that was already shipping, so this is a re-binding rather than a recolour. Every dimmed surface is now built the way the artwork filters are. C3 · Token Coverage - Both orientations round to the same 4 now. Vertical rounded at 4 and horizontal at 6, and they set it at different levels — vertical on the component itself, horizontal one frame in on Content. The values match. The structures do not, and will not: moving the radius out to the component makes horizontal stop rendering it even with clipping on. Documented as a known difference rather than left looking like an oversight. C1 · Layer Structure & Naming
- The price layers agree on their names now. The discounted price was called
#pricein Default but#valuein Used and Expired, so the same layer answered to two names depending on the state. Large vertical also called its wrapperpricewhere every other version saysPrice Container. Both renamed. C1 · Layer Structure & Naming - The validity date used two different type styles. Vertical set it in
Secondary/Default/Fineand horizontal inSecondary/Bold/Fine— one line of text, one job, two weights depending on which way the card faced. Both areBold/Finenow. C2 · Variant & Property Naming - Four components became one.
Voucher Card Horizontal,Horizontal Voucher,Vertical VoucherandVoucher Assetall described the same card. They are nowAssetSize,OrientationandStateon a single 12-version component, which is what stopped four near-identical cards from drifting apart. C2 · Variant & Property Naming - The card has states at all. Two of the old components had no state axis, so a used or expired voucher had to be drawn by hand.
State = Default | Used | Expiredcovers it in every orientation and size, and there is a neutral default for the first time. C5 · Interaction State Coverage - Content is no longer frozen. Every string used to be hardcoded placeholder text, and the badges were fixed to whatever state the version represented. Title, description, price, original price and validity are all text layers now, and the badges are slots that take any Badge instance. C1 · Layer Structure & Naming
- The discount is a slot, not baked artwork. "35% off" used to be burned into the image frame, so changing it meant editing artwork. It is now
⤷ DiscountSlotholding a Badge. C6 · Asset & Icon Quality - Artwork arrives through a real slot. The asset was a raster photograph with a partner wordmark burned in, and the frame that held it was an ordinary layer named to look like a slot.
⤷ AssetSlotis now a genuine Figma slot, so artwork swaps without detaching. C6 · Asset & Icon Quality - The ticket notch is a vector. It was a raster mask that could not resize or recolour. It is now
Stub Cutout— a rectangle with two circles subtracted — so it scales with the card and takes the state colours. C6 · Asset & Icon Quality - The claim action is a real Button. "GET VOUCHER" was rotated text pretending to be a control. It is a
Button - XSmallinstance in the horizontal card's action rail. C4 · Native Mappability - The settings can map one to one. The old cards exposed six to eight independent booleans plus a four-value enum, a shape no native component matches. Three orthogonal enums, complete at twelve of twelve, map directly onto native parameters. C4 · Native Mappability
- Names follow the system.
Asset SizebecameAssetSize;DIscount Slotlost its stray capital and became⤷ DiscountSlot;blurbbecame#validityandtitlebecame#title;SubtractbecameStub Cutout; and the lowercasecontent/container/priceframes are nowContent,ContainerandPrice Container. C1 · Layer Structure & Naming - Price is one string per value. The current price was split into
#unitand#valuewhile the original price was already a single string — and both were called#value, so nothing downstream could tell them apart. They are now#priceand#originalPrice, which also lets the platform format currency for the locale rather than assuming symbol-then-number. C2 · Variant & Property Naming
- Check the dimmed badge label against its background. The badge keeps a white label on both dimmed surfaces: 2.79:1 on Used
#9A9FA7and 1.73:1 on Expired#C2C5CA, against the 4.5:1 that 12px bold normally needs. WCAG exempts text inside an inactive component, and a Used or Expired voucher is exactly that, so this is not a defect. It is worth a look anyway, because that badge is the thing telling someone the voucher is dead — the one label on a dead card that still has a job. Switching it totext/color-textwould read 5.22:1 and 8.42:1 without touching the surfaces. A11y - Rename the Filter overlay's shape to match the asset's. The
Stub Cutoutrename reached the asset panel but not theFilterframes used by Used and Expired, where the same shape is still calledSubtract— the Figma operation rather than what it draws. Small, and the last of that rename to land. Rename - Let the platform format the price. Pass an amount and a currency code, not a preformatted string.
NumberFormatterwith.currencyon iOS andNumberFormat.getCurrencyInstance(locale)on Android both place the symbol, separator and grouping correctly for the user's locale. Docs - Announce the card as one element with its state. Title, price, original price, validity and badge read separately is five fragments. Group them, include the saving rather than making the user compare two numbers, and put the state in the label so "Used" and "Expired" are not carried by dimming alone. A11y
- Document what Used and Expired mean for interaction. Both dim the card, but a used voucher may still open its details while an expired one may not. The design does not say which, and the difference matters for whether the card stays tappable. Docs
- Retire the four superseded components in Figma. Once usages are migrated, delete
Voucher Card Horizontal,Horizontal Voucher,Vertical Voucherand the standaloneVoucher Asset. Their assessments are kept on this site as the record of what drove the consolidation. Family
Every colour Voucher paints. State walks every text layer one step down the weak ramp, and twice over for Expired — the title runs text → weak → weaker, everything else weak → weaker → weakest. #originalPrice is the one exception: it starts at disabled and has nowhere left to go. The badges and the claim rail are nested instances, so they are named rather than valued. The two badges and the claim rail dim through the same surface-plus-overlay the artwork filters use, on one base with only the overlay weight moving: bg/color-bg-main under bg/color-bg-overlay at 40% for Used (#9A9FA7) and under bg/color-bg-overlay-weak at 24% for Expired (#C2C5CA). Every colour on the card is bound.
| Role | Token | Default | Used | Expired |
|---|---|---|---|---|
| Card | bg/color-bg-main | #FFFFFF | #FFFFFF | #FFFFFF |
| Artwork panel | bg/color-bg | #F6F9FD | #F6F9FD | #F6F9FD |
| Artwork filter | bg/color-bg-overlay-strong | – | #020E22 @ 56% | – |
| Artwork filter | bg/color-bg-disabled | – | – | #C2CFE5 |
| #title | text/color-text · -weak · -weaker | #0A2757 | #445C85 | #6780A9 |
| #description | text/color-text-weak · -weaker · -weakest | #445C85 | #6780A9 | #90A8D0 |
| #validity | text/color-text-weak · -weaker · -weakest | #445C85 | #6780A9 | #90A8D0 |
| #price | text/color-text-primary · -weaker · -weakest | #005CE5 | #6780A9 | #90A8D0 |
| #originalPrice | text/color-text-disabled | #C2CFE5 | #C2CFE5 | #C2CFE5 |
| Badge | set by the Badge instance in ⤷ BadgeSlot | #005CE5 | – | – |
| Badge · Used | bg/color-bg-main under bg/color-bg-overlay 40% | – | #9A9FA7 | – |
| Badge · Expired | bg/color-bg-main under bg/color-bg-overlay-weak 24% | – | – | #C2C5CA |
| Discount badge | set by the Badge instance in ⤷ DiscountSlot — Vertical only | #0A2757 | – | – |
| Discount badge · Used | bg/color-bg-main under bg/color-bg-overlay 40% | – | #9A9FA7 | – |
| Discount badge · Expired | bg/color-bg-main under bg/color-bg-overlay-weak 24% | – | – | #C2C5CA |
| Claim rail | set by the instance in ⤷ AssetSlot — Horizontal only | #0A2757 | – | – |
| Claim rail · Used | bg/color-bg-main under bg/color-bg-overlay 40% | – | #9A9FA7 | – |
| Claim rail · Expired | bg/color-bg-main under bg/color-bg-overlay-weak 24% | – | – | #C2C5CA |
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:voucher:1.0.0")
Import
import EastBlueDS import com.eastblue.ds.voucher.*
Planned API — the native library does not exist yet. The artifact is the Voucher family: all seven members ship in <code>com.eastblue.ds:voucher</code> and import <code>com.eastblue.ds.voucher.*</code>.
Sixteen properties: three enums, five booleans, five text properties and three slots. The five booleans are not parameters. Each one asks whether an optional piece of content is present, so natively the optional itself is the parameter — pass a validityDate and it shows, leave it out and it does not. Adding a separate hasValidityDate alongside validityDate would let a caller ask for a date that is never drawn. hasDiscountBadge carries one further constraint: Horizontal has no ⤷ DiscountSlot at all, so the parameter has nothing to act on there.
| Figma Property | SwiftUI | Compose |
|---|---|---|
| Orientation — Horizontal, Vertical | orientation: EBVoucherOrientation | orientation: EBVoucherOrientation |
| AssetSize — Small, Large | assetSize: EBVoucherAssetSize = .small | assetSize: EBVoucherAssetSize = Small |
| State — Default, Used, Expired | state: EBVoucherState = .default | state: EBVoucherState = Default |
| hasValidityDate — true, false | derived — validityDate != nil | derived — validityDate != null |
| hasDescription — true, false | derived — description != nil | derived — description != null |
| hasBadge — true, false | derived — badge != nil | derived — badge != null |
| hasDiscountBadge — true, false | derived — discountBadge != nil — Vertical only | derived — discountBadge != null — Vertical only |
| hasOriginalPrice — true, false | derived — originalPrice != nil | derived — originalPrice != null |
| Title (text) | title: String | title: String |
| Description (text) | description: String? | description: String? = null |
| Validity Date (text) | validityDate: String? | validityDate: String? = null |
| Discounted Price (text) | price: String | price: String |
| Original Price (text) | originalPrice: String? | originalPrice: String? = null |
| ⤷ AssetSlot (slot) | @ViewBuilder asset: () -> Asset | asset: @Composable () -> Unit |
| ⤷ BadgeSlot (slot) | badge: (() -> Badge)? = nil | badge: (@Composable () -> Unit)? = null |
| ⤷ DiscountSlot (slot) | discountBadge: (() -> Badge)? = nil | discountBadge: (@Composable () -> Unit)? = null |
EBVoucher( title: "Buy Load Pre-seeded SKU Voucher Sample", price: "PHP 100.00", originalPrice: "PHP 150.00", description: "This is the description of the voucher.", validityDate: "Validity: Dec 25 2022 - Jan 5 2023", orientation: .horizontal, assetSize: .small ) { EBVoucherAsset(image: "voucher-hero") }
EBVoucher( title = "Buy Load Pre-seeded SKU Voucher Sample", price = "PHP 100.00", originalPrice = "PHP 150.00", description = "This is the description of the voucher.", validityDate = "Validity: Dec 25 2022 - Jan 5 2023", orientation = EBVoucherOrientation.Horizontal, assetSize = EBVoucherAssetSize.Small ) { EBVoucherAsset(image = "voucher-hero") }
EBVoucher( title: "Buy Load Pre-seeded SKU Voucher Sample", price: "PHP 100.00", originalPrice: "PHP 150.00", description: "This is the description of the voucher.", validityDate: "Validity: Dec 25 2022 - Jan 5 2023", orientation: .vertical, assetSize: .large, discountBadge: { EBBadge("35% off") } ) { EBVoucherAsset(image: "voucher-hero") }
EBVoucher( title = "Buy Load Pre-seeded SKU Voucher Sample", price = "PHP 100.00", originalPrice = "PHP 150.00", description = "This is the description of the voucher.", validityDate = "Validity: Dec 25 2022 - Jan 5 2023", orientation = EBVoucherOrientation.Vertical, assetSize = EBVoucherAssetSize.Large, discountBadge = { EBBadge("35% off") } ) { EBVoucherAsset(image = "voucher-hero") }
EBVoucher( title: "Buy Load Pre-seeded SKU Voucher Sample", price: "PHP 100.00", originalPrice: "PHP 150.00", description: "This is the description of the voucher.", validityDate: "Validity: Dec 25 2022 - Jan 5 2023", orientation: .vertical, assetSize: .small, state: .used ) { EBVoucherAsset(image: "voucher-hero") }
EBVoucher( title = "Buy Load Pre-seeded SKU Voucher Sample", price = "PHP 100.00", originalPrice = "PHP 150.00", description = "This is the description of the voucher.", validityDate = "Validity: Dec 25 2022 - Jan 5 2023", orientation = EBVoucherOrientation.Vertical, assetSize = EBVoucherAssetSize.Small, state = EBVoucherState.Used ) { EBVoucherAsset(image = "voucher-hero") }
| Requirement | iOS | Android |
|---|---|---|
| The card is one element | .accessibilityElement(children: .combine) | Modifier.semantics(mergeDescendants = true) |
| State is spoken, not only dimmed | Append "Used" or "Expired" to the label | stateDescription carries it |
| The saving is stated | "PHP 100, reduced from PHP 150" rather than two bare numbers | Same, built into contentDescription |
| Artwork is decorative | .accessibilityHidden(true) on the image | contentDescription = null |
Do
Use Vertical in grids and carousels, Horizontal in lists where each row needs a description.
Don't
Don't put Horizontal in a two-column grid; at 312 wide it will not fit.
Do
Let State follow the voucher's own status.
Don't
Don't set State by hand — an expired voucher showing as claimable is worse than no card.
Do
Supply artwork through the asset slot.
Don't
Don't burn the discount or the partner logo into the image; both have their own slots.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Both orientations use one vocabulary. Text layers all carry the # prefix and no name is used twice. |
| C2 | Variant & Property Naming | Ready | Three enums with clean values, five booleans all on the has* prefix, and five text properties named for what they hold. The one wrinkle is that hasDiscountBadge exists on both orientations while only Vertical has a ⤷ DiscountSlot for it to act on. |
| C3 | Token Coverage | Ready | Every surface, every text layer, both artwork filters and every dimmed badge and rail are bound to system tokens — the bg/color-bg-overlay-strong and bg/color-bg-overlay* the file uses, not the main/voucher/* namespace this note once proposed. The dimmed states were the last gap and closed during the review. |
| C4 | Native Mappability | Ready | Three enums plus slots map directly. The claim action is a real Button instance rather than rotated text. |
| C5 | Interaction State Coverage | Ready | Default, Used and Expired across every orientation and size. Pressed belongs to the host list or the claim button. |
| C6 | Asset & Icon Quality | Ready | The notch is a vector subtract, both state filters are blended groups rather than flattened art, and the Used badges and claim rail dim through a surface plus an overlay, while Expired still swaps in a flat grey. Artwork and both badges are slots. The one raster is the placeholder inside ⤷ AssetSlot’s default content — an image a real voucher replaces, sitting on a bound bg/color-bg rectangle. |
2 Orientation × 2 AssetSize × 3 State = 12 versions, complete with no gaps.
| Orientation | AssetSize | Card size | Count |
|---|---|---|---|
| Vertical | Small | 162 × 278 | 3 |
| Vertical | Large | 162 × 341 | 3 |
| Horizontal | Small | 312 × 124 | 3 |
| Horizontal | Large | 344 × 124 | 3 |
View full Orientation × AssetSize × State breakdown (12 rows)
| Orientation | AssetSize | State | Card size | Node |
|---|---|---|---|---|
| Vertical | Small | Default | 162 × 278 | 5372:38310 |
| Vertical | Small | Used | 162 × 278 | 5372:38560 |
| Vertical | Small | Expired | 162 × 278 | 5372:38536 |
| Vertical | Large | Default | 162 × 341 | 5372:38326 |
| Vertical | Large | Used | 162 × 341 | 5372:38342 |
| Vertical | Large | Expired | 162 × 341 | 5372:38366 |
| Horizontal | Small | Default | 312 × 124 | 5372:38390 |
| Horizontal | Small | Used | 312 × 124 | 5372:38409 |
| Horizontal | Small | Expired | 312 × 124 | 5372:38436 |
| Horizontal | Large | Default | 344 × 124 | 5372:38463 |
| Horizontal | Large | Used | 344 × 124 | 5372:38482 |
| Horizontal | Large | Expired | 344 × 124 | 5372:38509 |
bg/color-bg rectangle under a raster image. All of them now stack bg/color-bg-main under an overlay, with only the weight separating the states: 40% for Used, 24% for Expired. Used lands back on #9A9FA7, the colour that was already shipping, so this is a re-binding rather than a recolour.Secondary/Default/Fine and horizontal in Secondary/Bold/Fine — one line of text, one job, two weights depending on which way the card faced. Both are Bold/Fine now.#price in Default but #value in Used and Expired, so one layer answered to two names depending on state. Large vertical also called its wrapper price where every other version says Price Container. Both renamed.AssetSize is. Orientation was the right axis on the evidence too: the two are not one card rotated but two compositions, ordering their content differently and carrying different slots.Orientation now gets a card each, with AssetSize, State and the five booleans as controls.get_node_info, which returns variant properties only. They came from the property panel itself. The Properties section and the demo panel now carry all 16.text/color-text-heading, -body, -link, bg/color-bg-subtle and two main/voucher/* overlay names appear nowhere in the file’s selection colours. Every hex was right; every name was made up. They survived because the page had no colours table — spec cards do not render token, so the names were never drawn anywhere a reader could check them.#9BB0D0 standing in for the whole Expired column. A Colors by State table now carries all of it.Padding V of 0 that is 8, and a gap of 9.5 that is not a gap at all but Auto distributing leftover space. The component itself is 0 padding and 0 gap: the artwork butts straight against the content. All seven keys are read from the Auto layout panels now, none derived..eb-preview-vch declared font-family: inherit. Three of the five text layers are Secondary/*, so BarkAda is the root and #title and #price name Proxima Soft themselves. inherit happened to give the majority the right face and the other two the wrong one, which is why nobody caught it by eye.#title / #description, #price / #originalPrice. Sixteen rows now, one per panel property. The five booleans are marked derived rather than given parameters: each asks whether an optional is present, so the optional is the parameter and a separate hasValidityDate would let a caller ask for a date that never draws.gcash/east-blue-ios and com.gcash.eastblue:components:1.0.0, with no Import line. It now cites the Voucher family artifact com.eastblue.ds:voucher:1.0.0 and imports com.eastblue.ds.voucher.*, shared by all seven members.Orientation plus Used to show the state parameter. The three Code Connect rows are gone; C7 stays blocked while no native library exists.text/color-text would read 5.22:1 and 8.42:1 without touching the surfaces.Voucher Card Horizontal, Horizontal Voucher, Vertical Voucher and Voucher Asset fold into AssetSize × Orientation × State.⤷ DiscountSlot.AssetSize, ⤷ AssetSlot, ⤷ BadgeSlot, ⤷ DiscountSlot, Stub Cutout, #title, #validity, and Content / Container / Price Container in PascalCase.#unit + #value and a second #value became #price and #originalPrice, clearing a duplicate-name collision and letting the platform format currency.Subtract — the last of the Stub Cutout rename to land.