Content display
Components for presenting text, media, and structured data.
These components carry the product's information rather than its actions. In Senco that information is mostly money — amounts, rates, fees, status — so the rules below lean hard on alignment, precision, and never letting a number be ambiguous about its currency.
Components
| Component | Purpose | Element |
|---|---|---|
| Card | Surface transaction details and summary breakdowns | <section> or <article> |
| Badge | Label status, category, or count | <span> |
| Country flag | Show a country's flag as a circular image | <img> |
| Scroll area | Custom scrollable container with styled scrollbar | <div> |
| Hover card | Rich content preview on hover | <div role="dialog"> |
| Table | Display structured data in rows and columns | <table> |
| List | Unordered list for structured content | <ul> |
| Disclosure | Legal, regulatory, and compliance text | <section> |
Card
Used for transaction detail layouts — FROM/TO sections with person info and payment methods, plus summary breakdowns with fee, rate, and total.
- Built with
data-slot="form-card"pattern from the prototype - Uses CountryFlag for sender/recipient identity
- Summary rows use
tabular-numsfor aligned numbers
Anatomy
┌─────────────────────────────────┐
│ Heading │ --ds-text-lg / 600
│ ───────────────────────────── │ --ds-border
│ Label Value │ tabular-nums, right-aligned
│ Label Value │
│ ───────────────────────────── │
│ Total Value │ weight 700
└─────────────────────────────────┘
padding: --ds-formcard-padding (space-4)
radius: --ds-formcard-radius (12px)
shadow: --ds-formcard-shadow (xs)
Variants
| Variant | Tokens | Use |
|---|---|---|
| Form card | --ds-formcard-* | Mobile screens, grouped fields |
| Flow card | --ds-flowcard-* — space-5 padding, shadow-md, radius-xl | Transfer flow steps |
| Contact card | --ds-contactcard-* | Selectable recipient in a list |
API
type CardVariant = "form" | "flow" | "contact";
interface CardProps {
variant?: CardVariant; // default: "form"
heading?: string;
headingLevel?: 2 | 3 | 4; // default: 3 — follows the page hierarchy, not the desired size
as?: "section" | "article"; // default: "section"
interactive?: false; // a clickable card is Contact card; presentational cards have no states
children: React.ReactNode;
}
States
Cards are static unless they are selectable. A selectable card (contact card) has hover, focus, and selected states; a presentational card has none, and giving it a hover effect signals an interaction that does not exist.
| State | Contact card |
|---|---|
| Default | --ds-contactcard-bg, --ds-contactcard-border |
| Hover | --ds-contactcard-hover-bg, shadow raises to --ds-contactcard-hover-shadow |
| Focus-visible | --ds-focus-ring |
| Selected | --ds-primary border, --ds-primary-light fill |
Hover raises the shadow and never changes the border color — border change is reserved for selection, so the two states stay distinguishable.
Behavior
- Cards do not nest. A card inside a card should be a level-2 inset or a divided section. See the surface layering model in Color.
- Summary rows use
tabular-numsso the decimal points align down the column. Without it, a fee of3.50and a total of253.50do not line up and the breakdown stops reading as arithmetic. - A whole card is only clickable when the card is the control. Otherwise the action is a button inside it — a card with one clickable region and one non-clickable region is a card users tap in the wrong place.
Content
A card has a heading. A card without one is a <div> with a border, and a screen reader user has no way to skip past it.
Accessibility
- A card has a heading, and the heading level follows the page hierarchy rather than being chosen for size.
- A clickable card wraps its content in a single
<button>or<a>, not adivwith a click handler. - Never place two independently focusable controls inside a clickable card — the nesting is invalid and the keyboard behavior is undefined.
Keyboard
A presentational card is not focusable. A Contact card is one <button> and takes one tab stop.
| Key | Action |
|---|---|
Tab | To the card, when interactive |
Enter / Space | Select |
Tokens
--ds-formcard-bg · --ds-formcard-border · --ds-formcard-radius · --ds-formcard-shadow · --ds-formcard-padding · --ds-flowcard-* · --ds-contactcard-*
From
To
Badge
Short label for status, category, or count.
- Variants: default, secondary, outline, destructive
- Used in transaction lists and status indicators
Anatomy
| Part | Token |
|---|---|
| Dot (status variants) | --ds-space-1 gap to label |
| Label | --ds-text-2xs or --ds-text-xs, weight 500 |
| Padding | --ds-space-1 block, --ds-space-2 inline |
| Radius | --ds-radius-full |
Badge or status indicator
A badge is a label. A status indicator is a badge that carries state and must therefore carry icon and text alongside its color. Transaction status in Senco always uses the status form — see Status indicators.
Variants
| Variant | Dimension | Fill | Use |
|---|---|---|---|
default | style | --ds-surface | Neutral label |
secondary | style | --ds-primary-light | Category |
outline | style | none, --ds-border | Low emphasis |
destructive | style | --ds-error-bg | Count needing attention |
API
interface BadgeProps {
variant?: "default" | "secondary" | "outline" | "destructive"; // default: "default"
count?: number; // caps display at 99+
children?: React.ReactNode; // the label; required unless count is given
}
A badge is not interactive and carries no state semantics. A badge that means "this transfer is pending" is a Status badge.
States
Presentational. A badge has no interaction states — giving it a hover effect promises an interaction that does not exist.
Behavior
- Badges never truncate. A badge whose label does not fit means the label is too long, not that the badge is too small.
- Count badges cap at 99+.
- A badge is not interactive. A tappable chip is a toggle group item.
Content
One or two words, or a count capped at 99+. A badge never truncates — a label that does not fit is too long.
Accessibility
- A badge's text is its meaning. A color-only badge is a WCAG 1.4.1 failure.
- A count badge is folded into the accessible name of what it annotates ("Activity, 3 new"), not announced as an orphan number.
Keyboard
Not focusable and not interactive.
Tokens
--ds-surface · --ds-primary-light · --ds-error-bg · --ds-radius-full · --ds-text-2xs
Country flag
Circular country flag image, replacing traditional avatars. In Senco, user identity is tied to their country.
- Renders PNG from
/flags/{code}.pngvia Next.js Image - Sizes: 16px (inline), 20px (table rows), 28px (list items), 36px (cards)
- Circular crop with
rounded-full
Why flags instead of avatars
Senco's core object is a cross-border transfer, so the single most useful thing to show next to a recipient is which country the money is going to. A profile photo answers a question users are not asking, costs a network request per row, and is the first thing to fail on a slow connection.
Anatomy
A single circular image. No border, no ring, no status dot — a flag that carries a second signal stops reading as a country.
Variants
| Variant | Dimension | Size |
|---|---|---|
| Inline | layout | 16px, always decorative |
| Table row | layout | 20px |
| List item | layout | 28px |
| Card | layout | 36px |
API
interface CountryFlagProps {
countryCode: string; // ISO 3166-1 alpha-2
countryName: string; // used as alt only when decorative is false
size?: 16 | 20 | 28 | 36; // default: 20
decorative?: boolean; // default: true — the country is normally already in the text
}
At size={16} the flag is always decorative: several national flags are indistinguishable at that size.
States
| State | Rendering |
|---|---|
| Loaded | Circular crop at the requested size |
| Not loaded | Country-code initials circle — the normal case under Save-Data and offline |
Behavior
- The flag is decorative when the country name is already visible in text, and
alt=""in that case. It carriesalt="Nigeria"only when it is the sole indicator of the country. - Under Save-Data or offline, the flag falls back to a country-code initial circle rather than a broken image. Financial information stays fully readable with zero images loaded — see Design principles.
- A flag is never the only indicator of a currency. Currency is always stated as a code.
Content
None of its own. When decorative it carries alt=""; when it is the only country indicator its alt is the country name, never the country code.
Accessibility
- Flags are images, not text, so they can never carry meaning alone.
- Several countries share visually similar flags at 16px. At inline size the flag is always decorative.
Keyboard
Not focusable and not interactive.
Tokens
--ds-size-avatar · --ds-radius-full · --ds-surface (initials fallback)
Scroll area
Custom scrollable container with a styled, auto-hiding scrollbar.
- Scrollbar fades in on hover/scroll, fades out when idle
- Used for payment method lists and long content areas
Anatomy
A clipping container and a styled scrollbar. The fade is cosmetic — content must stay reachable when the bar is invisible.
Variants
| Variant | Dimension | Use |
|---|---|---|
| Vertical | layout | Payment method lists, long content |
| Horizontal | layout | Quick-action rows on mobile |
API
interface ScrollAreaProps {
label: string; // accessible name — a scroll region without one is unnavigable
maxHeight?: string;
children: React.ReactNode;
}
Renders tabIndex={0} on the scrollable element. A custom scroll container that is not focusable cannot be scrolled without a pointer.
States
| State | Rendering |
|---|---|
| Idle | Scrollbar faded out |
| Hovered or scrolling | Scrollbar faded in |
| Focused | 3px solid --ds-focus outline, inset — it is a tab stop |
Behavior
The scrollbar fades in on hover or scroll and out when idle. Scroll position is preserved across re-renders. Smooth scrolling becomes instant under reduced motion.
Content
None of its own. A horizontal scroll area must keep its first two items fully visible — an item that starts off-screen is an item most users never find.
Accessibility
- A scrollable region is focusable (
tabindex="0") so keyboard users can scroll it. A custom scroll container without this is unreachable without a mouse. - The region has an accessible name describing what it contains.
- Scroll behavior respects
prefers-reduced-motion— smooth scrolling becomes instant. - Content must remain reachable when the scrollbar is hidden; the fade is cosmetic only.
Keyboard
| Key | Action |
|---|---|
Tab | Focus the region |
↑ ↓ PageUp PageDown | Scroll |
Tokens
--ds-gray-300 (thumb) · --ds-focus-ring
Hover card
Rich content preview that appears on hover. Shows profile info with country flag, name, location.
- More detailed than a Tooltip — supports structured content
- Opens on hover with a short delay
- Not touch-friendly — provide alternative access on mobile
Anatomy
Trigger, card surface, and an arrow. The card carries structured content — flag, name, location — unlike a Tooltip, which is plain text.
Variants
One. The touch equivalent is a Sheet, supplied through mobileFallback.
API
interface HoverCardProps {
trigger: React.ReactNode;
children: React.ReactNode;
openDelay?: number; // default: 300
closeDelay?: number; // default: 150 — long enough to move the pointer into the card
mobileFallback: React.ReactNode; // required — opens as a sheet on touch
}
mobileFallback is required rather than optional. Content reachable only by hover does not exist for most of this product's users.
States
| State | Rendering |
|---|---|
| Hidden | Default |
| Opening | After openDelay, --ds-ease-out |
| Shown | Stays while the pointer is over the trigger or the card |
| Closing | After closeDelay |
On touch there is no hover state at all — the trigger opens mobileFallback as a Sheet.
Behavior
| Property | Value |
|---|---|
| Open delay | ~300ms |
| Close delay | ~150ms, so the pointer can travel into the card |
| Z-index | --ds-z-popover |
| Dismiss | Esc, pointer leaves both trigger and card |
The mobile problem
There is no hover on touch. Hover card is a desktop-only enhancement, and everything inside it must be reachable another way — on mobile the same content opens in a sheet on tap. Content that exists only in a hover card is content that does not exist for most of Senco's users.
Content
Structured detail the trigger cannot carry — a country, a payment method, a last-used date. Never an action the user needs.
Accessibility
- Opens on keyboard focus as well as pointer hover (SC 1.4.13 Content on Hover or Focus).
- Dismissible with
Escwithout moving the pointer. - Hoverable — the card stays open while the pointer is over it.
- Persistent — it does not auto-dismiss on a timer.
- Interactive content inside requires a focus trap and a close control; if the card has interactive content, it should probably be a popover instead.
Keyboard
| Key | Action |
|---|---|
Tab | Focusing the trigger opens the card |
Esc | Dismiss without moving the pointer |
Tokens
--ds-white · --ds-border · --ds-shadow-lg · --ds-radius-lg · --ds-z-popover
Table
Structured data in a scannable grid. Used for transaction history on larger screens.
- Right-align numeric columns for scannability
- Rows show recipient with country flag, amount, status badge, and date
Anatomy
| Part | Token |
|---|---|
| Header background | --ds-table-header-bg |
| Row stripe | --ds-table-stripe |
| Cell padding | --ds-space-3 block, --ds-space-4 inline |
| Border | --ds-border |
Variants
| Variant | Dimension | Use |
|---|---|---|
| Table | layout | Pointer-primary surfaces |
| Stacked list | layout | Below --ds-bp-mobile, via mobileRenderer — never a horizontal scroller |
API
interface TableColumn<T> {
key: keyof T & string;
header: string;
align?: "start" | "end"; // "end" for every numeric column
numeric?: boolean; // applies tabular-nums
sortable?: boolean;
}
interface TableProps<T> {
caption: string; // required; may be visually hidden
columns: Array<TableColumn<T>>;
rows: T[];
rowKey: (row: T) => string;
sort?: { key: string; direction: "asc" | "desc" };
onSortChange?: (sort: { key: string; direction: "asc" | "desc" }) => void;
stickyHeader?: boolean; // default: true
mobileRenderer?: (row: T) => React.ReactNode; // below --ds-bp-mobile the table becomes a list
}
States
| State | Rendering |
|---|---|
| Default | Header sticky, rows striped with --ds-table-stripe |
| Sorted | aria-sort on the active header, direction indicator beside the label |
| Row hover | --ds-surface, only when rows are interactive |
| Empty | Replaced by an empty state of the right type, never a headerless blank |
| Loading | Skeleton rows matching the final row height |
Behavior
- Numeric columns are right-aligned and use
tabular-nums. Text columns are start-aligned. A centered numeric column is unscannable. - Currency code accompanies every amount. A table of bare numbers in a multi-currency product is a defect.
- On mobile, the table becomes a stacked list of transaction rows rather than a horizontally scrolling table. A horizontally scrolling financial table hides the amount column, which is the column the user came for.
- The header is sticky on long tables.
Content
Every numeric column states its currency, either per cell or in the column header. A header-level currency does not excuse a mixed-currency column.
Accessibility
- Real
<table>markup with<th scope="col">. A grid built from divs has no row/column relationships for a screen reader. <caption>names the table, even when visually hidden.- Sortable headers are
<button>elements inside<th>witharia-sort. - Row striping is decorative; row identity comes from the row header cell.
Keyboard
| Key | Action |
|---|---|
Tab | Through sortable headers and interactive cells |
Enter / Space | Toggle sort on a focused header |
Tokens
--ds-table-header-bg · --ds-table-stripe · --ds-border · --ds-space-3 · --ds-space-4
| Contact | Amount | Status | Date |
|---|---|---|---|
| -250.00 USD | Completed | Apr 15 | |
| +1,200.00 GHS | Processing | Apr 14 | |
| -800.00 USD | Failed | Apr 13 |
List
Unordered list with primary-colored bullets.
- Used for requirements, feature lists, and options
- Bullet color uses
--ds-primaryfor visual emphasis
Anatomy
A list container and its items. Nesting is permitted one level; deeper structure is a Tree view.
Variants
| Variant | Dimension | Element |
|---|---|---|
| Unordered | type | <ul> — bullets in --ds-primary |
| Ordered | type | <ol> — when the numbering is meaning, not decoration |
API
interface ListProps {
as?: "ul" | "ol"; // default: "ul" — use "ol" when the order is meaning
children: React.ReactNode;
}
States
Presentational.
Behavior
Renders items in the order given. Nesting is permitted one level; deeper structure is a Tree view.
Content
Items are parallel in form — all noun phrases or all verb phrases, not a mixture.
Accessibility
Real <ul> / <ol> markup. A screen reader announces "list, 5 items", which tells a user how much is ahead of them — a stack of styled divs announces nothing. Ordered content (steps, ranked options) uses <ol>, because the numbering is meaning, not decoration.
Keyboard
Not focusable and not interactive.
Tokens
--ds-primary (bullet) · --ds-space-2 · --ds-text-base
- •Valid government-issued photo ID
- •Proof of residential address
- •Active bank account or mobile wallet
Disclosure
Legal, regulatory, and compliance text container.
- Text at caption size (12px) in muted color
- Variant-based icons: regulatory (ShieldCheck), crypto (TriangleAlert), biometric (Fingerprint)
- Supports inline links and optional checkbox for acknowledgement
| Variant | Icon | Usage |
|---|---|---|
| default | None | General informational disclosure |
| regulatory | ShieldCheck | Licensing, compliance, legal notices |
| crypto | TriangleAlert | Risk warnings for crypto/volatile assets |
| biometric | Fingerprint | Biometric data collection notices |
Small text is not hidden text
Disclosure text is 12px and muted, which is the smallest and lowest-contrast combination in the system. Two rules keep that from becoming a dark pattern:
- Muted at 12px still clears 4.5:1 (
--ds-mutedis#636971for exactly this reason). It is small, not faint. - Anything the user must act on — a risk they are accepting, a fee they are agreeing to — is not a disclosure. It is a notice at body size in the main flow. Disclosure carries required context, not decisions.
A crypto volatility warning shown at 12px below the fold, when the user is about to convert their salary, is a compliance checkbox rather than a disclosure of risk.
Anatomy
Variant icon inline-start, text block, optional acknowledgement checkbox below. The icon is aria-hidden — the variant's meaning is in the words.
Variants
| Variant | Dimension | Icon | Use |
|---|---|---|---|
default | type | none | General informational disclosure |
regulatory | type | ShieldCheck | Licensing, compliance, legal notices |
crypto | type | TriangleAlert | Risk warnings for volatile assets |
biometric | type | Fingerprint | Biometric data collection |
API
interface DisclosureProps {
variant?: "default" | "regulatory" | "crypto" | "biometric"; // default: "default"
acknowledgement?: {
checked: boolean;
onChange: (checked: boolean) => void;
label: string; // the full statement being agreed to, never "I agree"
};
children: React.ReactNode;
}
Disclosure carries required context. Anything the user must decide on — a risk they accept, a fee they agree to — is an Alert at body size in the main flow, not 12px text below the fold.
States
| State | Rendering |
|---|---|
| Default | Icon, 12px --ds-muted text |
| With acknowledgement | Checkbox with the full statement as its label |
| Acknowledged | Checkbox checked; the statement stays visible |
Behavior
Always visible — never collapsed behind a disclosure control. An acknowledgement checkbox, when present, blocks submission until checked and the statement stays visible after checking.
Content
Plain language, not the legal source text. If the statement cannot be said plainly it is probably a decision, and decisions belong in an Alert at body size.
Accessibility
- The acknowledgement checkbox is a real checkbox with a visible label, and the label is the full statement being agreed to — not "I agree".
- Inline links inside disclosure text are underlined, like all inline links.
- The icon is
aria-hidden; the variant's meaning is carried by the text.
Keyboard
| Key | Action |
|---|---|
Tab | Inline links, then the acknowledgement checkbox |
Space | Toggle the acknowledgement |
Tokens
--ds-muted · --ds-text-xs · --ds-surface · --ds-radius-md
Transfer processing times may vary depending on the recipient's bank and country.
Senco is licensed and regulated by the Financial Services Authority. Your funds are held in segregated accounts.
Cryptocurrency values are volatile. You may lose some or all of your investment. Past performance is not indicative of future results.
Design and accessibility
- Cards must have a clear heading and purpose
- Heading hierarchy is sequential (no skipping levels)
- Icons never convey meaning without an accompanying text label
- All text meets contrast requirements
Rules that apply to every component here
- Every amount carries its currency code. No exceptions, including inside a table whose header names the currency.
- Numeric columns use
tabular-numsand right alignment. - Nothing depends on an image loading. Flags, avatars, and media all have text fallbacks.
- Structure is markup, not styling. Tables are
<table>, lists are<ul>, headings are headings. - Presentational surfaces have no interactive states. A hover effect is a promise.
Do and don't
| Do | Don't |
|---|---|
Right-align amounts with tabular-nums | Center a numeric column |
| Show "250.00 USD" | Show "250.00" and rely on a flag |
| Stack a table into rows on mobile | Let a financial table scroll horizontally |
| Give a clickable card a single control | Put two buttons inside a clickable card |
| Put decisions in a notice at body size | Bury a risk warning in 12px disclosure text |
Known gaps
- Card has three token sets (
formcard,flowcard,contactcard) with no documented rule for when a fourth would be justified. - Hover card's mobile sheet equivalent is specified here but not implemented as a component.
- Table sorting, filtering, and empty state are undocumented.
- No skeleton variant is defined for any component on this page.