A display-only table row — a leading label and however many data cells you drop into its Columns Slot. Comes in Header and Content roles, each with a disabled state.
no. of columns variant matrix. Table Row now composes Table Label and Table Cell as real instances through slots, so column count is whatever you put in the Columns Slot rather than a property to pick from. Settings are Role (Header / Content) and State (Default / Disabled) — 4 versions in place of 3 components and 14 variants. Rows are display-only by design, so there is no pressed or selected state. Two follow-up passes cleared the rest. The first moved #description to the 10px token, dimmed every text layer when disabled, and settled all three swappable areas on ⤷ …Slot. The second went through the two primitives: one setting name across all three components, text layers named after the properties that fill them, disabled built into Table Label and Table Cell rather than painted on by hand, and an empty version that is finally empty. Every colour is bound. Code Connect stays unmapped because the native library doesn't exist yet.Sticker sheet shows Table instances stacked on a Template Screen to build a static 6-row pattern — 1 header row + 5 content rows. No scroll, no sort, no selection.
Columns Slot takes any number of Table Cell instances, and each cell carries its own Asset Slot and text. Column count, icons, and cell content are all the consumer's to set.Role and State are PascalCase settings with Title Case values, and Default / Disabled is a valid subset of the standard interaction set. All three components in the family now use the same two names, and every text property matches the layer it fills. The no. of columns property, with its period, is gone.Table Label and Table Cell are placed as instances inside slots rather than redrawn. The old complaint was that both atoms were published but never actually slotted.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Header | Yes | Yes | Role=Header | 360 × 68 on a #F6F9FD ground. Label and cell text both use Primary/Label/Small, so the whole row is Proxima Soft. |
| Content | Yes | Yes | Role=Content | 360 × 70 on white. The label drops to Primary/Label/Fine and the cell text becomes Secondary/Light/Caption — the one place the row uses BarkAda. |
| Disabled | Yes | Yes | State=Disabled | Every text layer dims to text/color-text-disabled. The background does not change — a disabled Content row stays white and a disabled Header row stays #F6F9FD. Both primitives carry their own disabled version, so the row swaps to them. |
| Pressed | N/A | N/A | Not built | Not needed — rows are display-only and carry no tap target. |
| Selected | N/A | N/A | Not built | Not needed — no row selection in this pattern. |
- One name for one setting, across all three components. v2.2:
Table Cellcalled its settingTypewhileTable RowandTable Labelcalled the same thingRole. All three sayRolenow. It matters beyond tidiness:Role=Headeris what becomes an accessibility heading on both platforms, so the name is describing a real role. C2 · Variant & Property Naming - Every text layer is named after the property that fills it. v2.2: the cell's text layer was
#description— a name copied fromTable Label, promising a second line the cell never had. It is#textnow, filled by a newTextproperty.Table Label's#labelbecame#titleto match itsTitleproperty. Property name and layer name agree everywhere. C1 · Layer Structure & Naming - The empty version is actually empty. v2.2:
Role=EmptyonTable Cellheld a stray blue checkmark — a copy of another component parked in the set, drawing an unflattened shape on an unbound#025ae9. Both primitives now draw nothing forRole=Empty, which is what a column with no value for that row should look like. C6 · Asset & Icon Quality - The disabled look lives in the primitives, not in each copy. v2.2:
Table LabelandTable Celleach publish their own Disabled version, so a disabled row swaps to them. Before, every copy had its colours painted over by hand — which meant the disabled look did not travel if either primitive were used anywhere else. C5 · Interaction State Coverage - Every colour is bound to a named system colour. v2.2: checked against Figma's selection colours. The set paints six colours and the panel lists exactly six names —
bg/color-bg,bg/color-bg-main,border/color-border-weak,border/color-border,text/color-textandtext/color-text-disabled. The icon placeholder was documented as a hardcoded#C2C6CF; it isborder/color-borderat#D7E0EF.Table Label's description sits ontext/color-text-weaker. C3 · Token Coverage - One row replaces three components. v2.0: rebuilt on node
5734:37611asTable Row.Table LabelandTable Cellare now placed as real instances inside slots instead of being published and ignored. 3 components and 14 variants become 1 component and 4. C1 · Layer Structure & Naming -
no. of columnsis gone. v2.0: column count is no longer a property. TheColumns Slotholds however manyTable Cellinstances you drop in, which is what data-driven was always meant to mean. The period in the property name goes with it. C2 · Variant & Property Naming - The icon placeholder is a slot. v2.0: every
Table Cellcarries a 24 × 24Asset Slotholding a swappablePlaceholderinstance, andTable Labelhas one too. The hardcoded#C2C6CFcircle and theicon=yes/noboolean are both gone. C6 · Asset & Icon Quality - The row maps to native primitives. v2.0: a slot-based row is just an
HStack/Rowof cells, so it no longer depends on a platformTableprimitive that phones don't have. Rows stay 360 wide for now because they sit inside a fixed component group; they move to fill when the screens that need it are reworked. C4 · Native Mappability - The label description is readable now. v2.1:
#descriptionmoved from 8 / 12 to the 10px token used elsewhere in the system — BarkAda SemiBold 10 / 15. 10px is the accepted middle ground; moving to the 12px token stays open as a recommendation. C3 · Token Coverage - The disabled state dims every text layer. v2.1:
#descriptionnow takes#C2CFE5inState=Disabled(5761:37774), matching#labeland the cells. A disabled row dims as one thing. C5 · Interaction State Coverage - Slot layer names follow one pattern. v2.1: all three slots renamed to the
⤷ …Slotform —⤷ ColumnSloton the row, and⤷ AssetSloton bothTable LabelandTable Cell. Table Scheduling uses the same pattern. C1 · Layer Structure & Naming - Interaction states are intentionally minimal. v2.0: reviewed and settled. Rows are display-only — no tap target, no selection — so pressed, focused, and selected are not needed.
State=Disabledis built for rows showing unavailable data. C5 · Interaction State Coverage
- Move
#descriptionfrom the 10px token to the 12px one. 10px was chosen as a middle ground and matches other components today, so this isn't urgent. 12px is the target — it's the floor the rest of the system's body text sits on, and small supporting text is exactly where legibility is thinnest. A11y - Write the slot naming convention into the guidelines. The Table family now uses
⤷ …Slot—⤷ ColumnSlot,⤷ AssetSlot,⤷ CurrencySlot. The carousel components use⤷without the suffix (⤷ LeadingIcon,⤷ Violator) and no prefix at all on top-level slots (Banner,Background). Both are internally consistent; the library needs one rule so new slot-based components stop diverging. Docs - Document that rows are display-only. There is no tap target and no selection by design. Write it down so native developers wrap the row in a non-interactive container rather than inferring a missing state. Docs
- Revisit the fixed 360 width when screens are reworked. Rows are 360 wide because they currently sit inside a fixed component group. The intent is to move to fill. Worth tracking so it doesn't get forgotten once those screens are edited. Composition
- Introduce a shared label/value token set with Inline Text. v2.2.1: Closed — there is no longer a set to share. This row already draws from the system-wide
text/*colours, and theinline-text/color/*tokens the recommendation wanted to converge on have been retired from the file. What is left belongs to Inline Text's own page, which still documents them. Token - Give disabled rows their own background. v2.2: No change needed — the row already does what the second half of this asked for. The recommendation said disabled reuses the Header
#F6F9FD, so a disabled row and a header row read alike in a stack. Exporting the set shows otherwise: a disabled Content row is still white and a disabled Header row is still#F6F9FD. Disabled changes text colour and nothing else. The claim described our own preview, which had an invented background rule, rather than the component. Token - Audit the colour token bindings. v2.2: Applied — done from Figma's selection-colours panel, which the old tooling could not read. Six colours, six names, no gaps. Details in the Resolved tab. Token
- Collapse the three-component family into one row primitive. v2.0: Applied —
Table Rowwith aColumns Slotand aRolevariant, exactly as proposed. 3 components and 14 variants become 1 component and 4. Property - Rename
no. of columnsto an integer, or drop it entirely. v2.0: Applied — dropped. Column count comes from the number ofTable Cellinstances in the slot. Rename - Replace the header icon placeholder with a named slot. v2.0: Applied —
Asset Sloton bothTable LabelandTable Cell, and theicon=yes/noboolean is gone. Slot - Add row interaction states. v2.0: Applied as far as it goes —
State=Disabledshipped. Pressed and selected were reviewed and dropped: rows are display-only. State - Reconsider whether Table belongs in a mobile-first DS. v2.0: Settled — Table stays, scoped to genuine multi-column data and rebuilt as a data-driven row. That was option (b) of the two paths originally offered. Family
- Raise the label description above 8px. v2.1: Applied —
#descriptionis now BarkAda SemiBold 10 / 15, on the shared 10px token. The move to 12px stays open as a recommendation. A11y - Dim
#descriptionin the disabled state. v2.1: Applied — it takes#C2CFE5alongside the label and cells. Token - Settle one slot naming convention for the component. v2.1: Applied —
⤷ ColumnSlotand⤷ AssetSlotthroughout. Settling it library-wide stays open as a Docs item. Rename
Every paint in the set is bound. The six tokens here are exactly the six the Figma selection-colors panel lists for node 5734:37611 — nothing is off-token, including the cell asset placeholder.
| Role | Element | Token | Value |
|---|---|---|---|
| Header | Background | bg/color-bg | #F6F9FD |
| — | Border | border/color-border-weak | #E5EBF4 |
| — | Label | text/color-text | #0A2757 |
| — | Cell text | text/color-text | #0A2757 |
| — | Cell asset | border/color-border | #D7E0EF |
| Content | Background | bg/color-bg-main | #FFFFFF |
| — | Border | border/color-border-weak | #E5EBF4 |
| — | Label | text/color-text | #0A2757 |
| — | Cell text | text/color-text | #0A2757 |
| — | Cell asset | border/color-border | #D7E0EF |
| Disabled | Label | text/color-text-disabled | #C2CFE5 |
| — | Cell text | text/color-text-disabled | #C2CFE5 |
| — | Background | — unchanged | – |
| — | Cell asset | — unchanged | – |
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:table:1.0.0")
Import
import EastBlueDS import com.eastblue.ds.table.*
Table Row publishes four properties — Role, State, ⤷ ColumnSlot and hasBorder. The rows beneath them map the two nested primitives, Table Label and Table Cell, which ship inside every row and have no page of their own. Both carry their own Role / Type and State variants, driven by the row rather than set independently, so neither takes a parameter here. All three components name the axis Role and expose their text as properties. Both primitives also publish Role=Empty — a variant that draws nothing, for a column that has no value on this row. It takes no parameter of its own: natively it is an absent Column entry, or one whose text is empty.
| Figma Property | SwiftUI | Compose |
|---|---|---|
| Role — Header, Content | role: .header / .content | role = EBTableRowRole.Header / Content |
| State — Default, Disabled | .disabled(true) | enabled = false |
| ⤷ ColumnSlot (slot) — Table Cell instances | columns: [Column] | columns: List<Column> |
| hasBorder — true, false | showsDivider: Bool = true | showsDivider: Boolean = true |
| Table Label → Title (text) | title: String | title: String |
| Table Label → Description (text) | description: String? | description: String? = null |
| Table Label → hasDescription — true, false | description == nil — nil hides the line | description == null |
| Table Label → hasAsset — true, false | leadingIcon == nil — nil hides the slot | leadingIcon == null |
| Table Label → ⤷ AssetSlot (slot) | leadingIcon: AnyView? | leadingIcon: @Composable (() -> Unit)? = null |
| Table Cell → Role — Header, Content, Transaction, Empty | Column.role: .header / .content / .transaction | role = EBTableCellRole.Header / Content / Transaction |
| Table Cell → Text (text) | Column.text: String | Column.text: String |
| Table Cell → hasAsset — true, false | Column.asset == nil — nil hides the slot | Column.asset == null |
| Table Cell → ⤷ AssetSlot (slot) | Column.asset: AnyView? | Column.asset: @Composable (() -> Unit)? |
// One Header row at the top of the table. EBTableRow( role: .header, label: "Header", columns: [ Column("Title", asset: Image("wallet")), Column("Title", asset: Image("bank")), Column("Title", asset: Image("card")) ] )
// One Header row at the top of the table. EBTableRow( role = EBTableRowRole.Header, label = "Header", columns = listOf( Column("Title", asset = { Icon("wallet") }), Column("Title", asset = { Icon("bank") }), Column("Title", asset = { Icon("card") }) ) )
// Column count must match the Header row above it. EBTableRow( role: .content, label: "Row Title", columns: [ Column("Data"), Column("Data"), Column("Data") ] ) .disabled(isUnavailable)
// Column count must match the Header row above it. EBTableRow( role = EBTableRowRole.Content, label = "Row Title", columns = listOf( Column("Data"), Column("Data"), Column("Data") ), enabled = !isUnavailable )
| Requirement | iOS | Android |
|---|---|---|
| Tabular semantics | On iPad/macOS use Table; on iPhone use accessibilityElement(children: .combine) per row so each reads as "Label, value, value". | Wrap in Modifier.semantics { collectionInfo = CollectionInfo(rowCount, columnCount) }. |
| Header vs content row | Use .accessibilityAddTraits(.isHeader) on header rows. | Use Modifier.semantics { heading() } on header rows. |
| Column headers without visible text | If icon-only header columns exist, provide .accessibilityLabel. | Set contentDescription on the icon slot. |
| Row-level tap | If rows become tappable, wrap row in Button with .accessibilityHint. | Wrap in Modifier.clickable with role = Role.Button. |
Do
Give every row in one table the same number of Table Cell instances.
Don't
Don't put 3 cells in the Header and 4 in the rows beneath it — the ⤷ ColumnSlot divides its 176px evenly and nothing aligns the two.
Do
Use Role=Header once, at the top of the table.
Don't
Don't reach for Header mid-table to emphasise a row — it reads as a second table starting.
Do
Put the row's subject in Table Label and its data in the cells.
Don't
Don't fold a fourth data point into the label to save a column — the label is 120px and fixed.
Do
Use Inline Text when a row carries a single value.
Don't
Don't use Table Row for a two-column label/value list — three cells share 176px, so each gets about 59px.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | One Table Row composing Table Label and Table Cell through slots, all named ⤷ …Slot. The cell's text layer is #text — it was #description, a name copied from Table Label that promised a second line the cell never had. |
| C2 | Variant & Property Naming | Ready | All three components name the axis Role with Title Case values, and both primitives expose their text as properties — Title and Description on Table Label, Text on Table Cell, each matching its layer name. Role=Empty draws nothing on both, as the name says. The no. of columns property and its period are long gone. |
| C3 | Token Coverage | Ready | Verified against the selection-colors panel: the set paints six colours and the panel lists exactly six tokens — bg/color-bg, bg/color-bg-main, border/color-border-weak, border/color-border, text/color-text, text/color-text-disabled. Table Label's #description is on text/color-text-weaker. |
| C4 | Native Mappability | Ready | A slot-based row is an HStack / Row of cells — no platform Table primitive needed. Fixed 360 width is deliberate for now and moves to fill when the screens are reworked. |
| C5 | Interaction State Coverage | Ready | Display-only by design, so pressed and selected are not needed. State=Disabled dims every text layer to text/color-text-disabled and leaves the row's own background alone. Both primitives publish their own Disabled variant, so the row swaps to them rather than overriding fills per instance. |
| C6 | Asset & Icon Quality | Ready | 24 × 24 ⤷ AssetSlot on both primitives, holding a swappable Placeholder instance drawn on border/color-border. |
Role (2) × State (2) = 4 published variants. hasBorder sits on top of them without adding any, so Table Row has 8 combinations behind 4 versions. Column count is not a version either — the ⤷ ColumnSlot takes however many Table Cell instances you drop in — three in every published variant, each one Role=Header or Role=Content to match the row. This replaces the old three-component family, which published 9 + 3 + 2 variants between them.
| Role | State | Dimensions | Background | Node |
|---|---|---|---|---|
| Header | Default | 360 × 68 | #F6F9FD | 5734:37630 |
| Header | Disabled | 360 × 68 | #F6F9FD | 5761:37748 |
| Content | Default | 360 × 70 | #FFFFFF | 5734:37657 |
| Content | Disabled | 360 × 70 | #FFFFFF | 5761:37773 |
Table Cell gained a fourth role. Property Mapping listed Header, Content, Empty; the primitive now also publishes Role=Transaction (nodes 9567:96672 and 9567:96676), added during the Table Transaction review so that row could stop overriding the cell's text style. Nothing about Table Row changed — a component it references did, which is why its own checks all passed while the page went stale.text/* colours, and the inline-text/color/* tokens no longer exist. Nothing to reconcile from here.Table Cell called its setting Type while Table Row and Table Label called the same thing Role. All three say Role now. It matters beyond tidiness: Role=Header is what becomes an accessibility heading on both platforms.#description, a name copied from Table Label that promised a second line the cell never had — it is #text now, filled by a new Text property. Table Label's #label became #title to match its Title.Role=Empty on Table Cell held a stray blue checkmark — a copy of another component parked in the set, drawing an unflattened shape on an unbound #025ae9. Both primitives now draw nothing for Role=Empty.Table Label and Table Cell each publish their own Disabled version, so a disabled row swaps to them. Before, every copy had its colours painted over by hand, which meant the disabled look did not travel if either primitive were used elsewhere.#C2C6CF; it is border/color-border at #D7E0EF. This closes the standing recommendation to audit the bindings.#F6F9FD, so the two read alike in a stack. Exporting the set shows otherwise: a disabled Content row is still white. The claim came from an invented CSS rule in the documentation preview, not from the component. Closed with no Figma change.#C8D3E5 against a real #D7E0EF; disabled rows were given a #F6F9FD background the component never had; and the preview root drew in the documentation face rather than Proxima Soft. Corrected against the SVG export of the whole set.Hug · 68 and Hug · 70, and the four spec sections carried font specs instead of text style names. Rebuilt from the Auto layout panels and the resolved styles: Primary/Label/Small, Primary/Label/Fine and Secondary/Light/Caption.Role; four usage guidelines written where there were none.7f5cafa (August 2026) and never got changelog entries, while the Overview tab referenced both throughout. Both are written above, reconstructed from that commit and from the Overview's own version-tagged records.#description moved to the shared 10px text style, up from 8px. 12px is still the target and stays as a recommendation — it is the floor the rest of the system's body text sits on.text/color-text-disabled.⤷ …Slot form — ⤷ ColumnSlot on the row, ⤷ AssetSlot on both primitives. Settling it library-wide stays open as a documentation item.Table, Table - Item and Table - Label published 9 + 3 + 2 versions between them, and only Table was ever placed on a screen. Table Row replaces all three with 4 versions, composing Table Label and Table Cell as real copies inside swappable areas rather than redrawing them.no. of columns was a text setting with a period in its name, and it fixed the number of columns at build time. Column count is now however many Table Cell copies you drop into ⤷ ColumnSlot.#C2C6CF circle behind an icon=yes/no toggle. Both Table Label and Table Cell now carry a 24 × 24 ⤷ AssetSlot holding a swappable Placeholder.Table needed — so Table stays, scoped to genuine multi-column data.State=Disabled was added for rows showing unavailable data.no. of columns naming — String enum with a period in the property name. Open#C2C6CF circle with no slot or instance swap. Open