A titled rail that scrolls Ad Space cards sideways, with an optional button in the header. One version, driven by a content slot.
Header holding #title and an optional button, over a ⤷ CarouselSlot that holds the cards. Names match Ad Space exactly, and the slot is the single control for what appears in the rail. It is badged Needs Refinement for one reason: nothing inside it is actually about ads. A title, a button, and a row of cards is the generic rail pattern, so the ad-specific framing is worth revisiting once the Carousel family is reviewed.Sits on dashboard and category surfaces as a sponsored rail — a section heading, an optional "Show more" button, and a horizontally scrolling row of Ad Space cards with the next one peeking at the right edge.
⤷ CarouselSlot takes whatever card is dropped into it. Every ad surface that needs a scrolling row uses this one component rather than a bespoke frame.Button - XSmall rather than a hand-built shape.Header, #title, and a ⤷ -prefixed slot. The duplicate hand-built button and the spacer node that used to sit in the header are both gone.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| hasCTA=false | N/A | N/A | default | Heading only. This is the default the component ships with. |
| hasCTA=true | N/A | N/A | Button - XSmall | Adds the "Show more" button at the right of the heading row. |
| ⤷ CarouselSlot | N/A | N/A | content slot | Selects which Ad Space version fills the rail. Promo is the default. |
| Peek | N/A | N/A | overflow clip | The rail is wider than the shell, so the next card is cut off at the right edge to signal scrollability. |
- One button in the heading, not two. The header used to carry a hand-built "Show more" — an
offsetframe, a spacer, and an arrow — alongside a realButton - XSmallinstance saying the same thing. The hand-built one is gone. C1 · Layer Structure & Naming - The spacer node is gone. A
_space_8instance was faking a gap between the label and the arrow. Spacer nodes have no native equivalent — auto layout spacing does the job — and it went with the hand-built button. C4 · Native Mappability - Names match Ad Space.
header-contentbecameHeader, and the emptyheaderframe wrapping it was removed. The heading text is#title, the same name Ad Space uses for the same job. C1 · Layer Structure & Naming - The one-value setting is gone. The component was a set with a single
Type=Defaultversion — a dropdown with nothing to choose. It is now a plain component; what varies is the slot content andhasCTA. C2 · Variant & Property Naming - Banner filling the rail edge to edge is intentional. A 320-wide Banner in a 360-wide shell leaves no room for the next card, so nothing peeks — Promo shows about 96px and Receipt about 20px. Confirmed as intended: Banner is the full-width option, and the rail still scrolls. No change to the inset or the gap. C1 · Layer Structure & Naming
- The next card now actually peeks. With Banner cards in the rail, the second card started exactly on the shell's right edge — nothing showed, so the rail read as a single card. Promo is now the default slot content, which leaves about 96px of the next card visible. C1 · Layer Structure & Naming
- Revisit whether this should be ad-specific. Strip the name away and this is a heading, an optional button, and a row of cards — the generic rail. Swap Ad Space for Carousel Card and it is the dashboard rail. Keeping it ad-specific was a deliberate call this pass, because calling it Carousel would collide with Carousel Card and Carousel Item. Settle it as a family decision when the Carousel family is reviewed, so the design system does not end up with one rail per card type. Family
- Give the heading a way to switch off.
hasCTAhides the button, but the heading itself is always on. Rails that sit under an existing section heading would repeat it. AhasHeaderboolean, matching thehasprefix already used forhasCTA, would cover that case. Property - Make the button a slot if the label ever varies. The button is a fixed
Button - XSmallinstance today. If "Show more" ever becomes "See all" or "View offers", promote it to⤷ ActionSlot, the same pattern Tooltip uses, so the label and icon can change without detaching. Slot - Confirm the heading colour is token-bound.
#titlerenders#072592and the button label#005CE5. Both should resolve to named colours rather than raw values. Worth a Dev Mode check, since the read-only tools used for this assessment report the hex without showing whether a token sits behind it. Token - Treat the rail as a list for assistive tech. Expose the rail as a single horizontally scrollable group with the heading as its label, and each card as an element within it, so screen reader users can skip the whole rail instead of stepping through every ad. A11y
- See siblings: Ad Space supplies every card in the rail. Carousel Card and Carousel Item are the non-ad equivalents and are the reason this component is not simply called Carousel. Family
One version. The heading row carries #title and an optional button; the slot below holds the cards and clips the last one.
Swift Package Manager
.package(url: "https://github.com/gcash/east-blue-ios", from: "1.0.0")
Gradle
implementation("com.gcash.eastblue:components:1.0.0")
Planned API — the native library does not exist yet. Snippets show the intended shape, not shipped code.
Figma properties mapped to the intended native parameters.
| Figma Property | SwiftUI | Compose |
|---|---|---|
| #title | title: String | title: String |
| hasCTA | action: EBAdCarouselAction? | action: (() -> Unit)? |
| ⤷ CarouselSlot | @ViewBuilder content: () -> Content | content: @Composable RowScope.() -> Unit |
EBAdCarousel(title: "Sponsored") { ForEach(ads) { EBAdSpace(.promo) { $0.image } } }
EBAdCarousel(title = "Sponsored") { ads.forEach { EBAdSpace(AdSpaceVariant.Promo) { Asset(it) } } }
EBAdCarousel( title: "Sponsored", action: .init(label: "Show more") { router.push(.allAds) } ) { ForEach(ads) { EBAdSpace(.promo) { $0.image } } }
EBAdCarousel( title = "Sponsored", action = { navController.navigate("ads") } ) { ads.forEach { EBAdSpace(AdSpaceVariant.Promo) { Asset(it) } } }
| Requirement | iOS | Android |
|---|---|---|
| Rail is one navigable group | accessibilityElement(children: .contain) on the rail, labelled by #title | Modifier.semantics { collectionInfo = CollectionInfo(1, itemCount) } |
| Heading is exposed as a heading | .accessibilityAddTraits(.isHeader) | Modifier.semantics { heading() } |
| Button reachable without scrolling the rail | Button precedes the rail in the accessibility order | Button precedes the rail in the traversal order |
| Clipped card is still reachable | Scroll to reveal on accessibility focus | bringIntoViewRequester on focus |
Do
Use Promo cards when the rail should read as scrollable — about 96px of the next card stays visible.
Don't
Don't expect a peek from Banner cards; at 320 wide they fill the rail by design.
Do
Let the heading describe the whole rail, and switch off the card titles inside it.
Don't
Don't repeat the same wording in the rail heading and in every card.
Do
Turn on the button only when there is somewhere further to go.
Don't
Don't ship a "Show more" that leads back to the same surface.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Two levels, names matching Ad Space. Banner filling the rail edge to edge is intentional. |
| C2 | Variant & Property Naming | Ready | The one-value Type setting was removed. hasCTA follows the boolean prefix convention. |
| C3 | Token Coverage | Needs Refinement | Colours are attested as token-bound but were not verifiable with read-only tools. Needs a Dev Mode confirmation. |
| C4 | Native Mappability | Ready | The spacer instance is gone. Heading plus scrolling row maps to a lazy row with a header on both platforms. |
| C5 | Interaction State Coverage | Not Applicable | The container has no states of its own. Pressed behaviour belongs to the cards and the button. |
| C6 | Asset & Icon Quality | Needs Refinement | The button's arrow uses a shape_full boolean operation — delegated to the iconography team, not fixable here. |
| C7 | Code Connect Linkability | Not Mapped | Blocked — the native library does not exist yet. |
| Aspect | Status | Notes |
|---|---|---|
| Property naming | Ready | hasCTA (bool), #title (text), ⤷ CarouselSlot (slot) map one to one. |
| Token coverage | Needs Refinement | Heading and button colours need a Dev Mode check before mapping. |
| Registration | Not Mapped | Blocked until the native library exists. |
1 component × 2 hasCTA values = 2 states. Rail content is set through the slot, not through a variant.
| hasCTA | Header | Node |
|---|---|---|
| false | #title only | 5703:38564 |
| true | #title + Button - XSmall | 5703:38564 |
Button - XSmall instance stays._space_8 spacer instance deleted with the hand-built button.header-content renamed to Header, the redundant header wrapper removed, and the heading text renamed to #title.Type setting removed — the component set became a plain component, which is why the node changed.