Skip to main content
ENKO

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

ComponentPurposeElement
CardSurface transaction details and summary breakdowns<section> or <article>
BadgeLabel status, category, or count<span>
Country flagShow a country's flag as a circular image<img>
Scroll areaCustom scrollable container with styled scrollbar<div>
Hover cardRich content preview on hover<div role="dialog">
TableDisplay structured data in rows and columns<table>
ListUnordered list for structured content<ul>
DisclosureLegal, 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-nums for 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

VariantTokensUse
Form card--ds-formcard-*Mobile screens, grouped fields
Flow card--ds-flowcard-* — space-5 padding, shadow-md, radius-xlTransfer 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.

StateContact 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-nums so the decimal points align down the column. Without it, a fee of 3.50 and a total of 253.50 do 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 a div with 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.

KeyAction
TabTo the card, when interactive
Enter / SpaceSelect

Tokens

--ds-formcard-bg · --ds-formcard-border · --ds-formcard-radius · --ds-formcard-shadow · --ds-formcard-padding · --ds-flowcard-* · --ds-contactcard-*

Card

From

United States flag
Carlos Rivera
United States
PayPal
carlos@paypal.me

To

Canada flag
Devon Kim
Canada
Visa
....8293
SNC-2026-0847Completed
You received$1,200.00 CAD
Service fee (%)-$24.00
Rate0.74 USD
You received$864.71

Badge

Short label for status, category, or count.

  • Variants: default, secondary, outline, destructive
  • Used in transaction lists and status indicators

Anatomy

PartToken
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

VariantDimensionFillUse
defaultstyle--ds-surfaceNeutral label
secondarystyle--ds-primary-lightCategory
outlinestylenone, --ds-borderLow emphasis
destructivestyle--ds-error-bgCount 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

Badge variants
DefaultSecondaryOutlineFailed

Country flag

Circular country flag image, replacing traditional avatars. In Senco, user identity is tied to their country.

  • Renders PNG from /flags/{code}.png via 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

VariantDimensionSize
Inlinelayout16px, always decorative
Table rowlayout20px
List itemlayout28px
Cardlayout36px

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

StateRendering
LoadedCircular crop at the requested size
Not loadedCountry-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 carries alt="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)

Country flag
United States flag36px
Canada flag28px
Kenya flag20px
United Kingdom flag16px

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

VariantDimensionUse
VerticallayoutPayment method lists, long content
HorizontallayoutQuick-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

StateRendering
IdleScrollbar faded out
Hovered or scrollingScrollbar faded in
Focused3px 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

KeyAction
TabFocus the region
↑ ↓ PageUp PageDownScroll

Tokens

--ds-gray-300 (thumb) · --ds-focus-ring

Scroll area

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

StateRendering
HiddenDefault
OpeningAfter openDelay, --ds-ease-out
ShownStays while the pointer is over the trigger or the card
ClosingAfter closeDelay

On touch there is no hover state at all — the trigger opens mobileFallback as a Sheet.

Behavior

PropertyValue
Open delay~300ms
Close delay~150ms, so the pointer can travel into the card
Z-index--ds-z-popover
DismissEsc, 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 Esc without 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

KeyAction
TabFocusing the trigger opens the card
EscDismiss without moving the pointer

Tokens

--ds-white · --ds-border · --ds-shadow-lg · --ds-radius-lg · --ds-z-popover

Hover card

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

PartToken
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

VariantDimensionUse
TablelayoutPointer-primary surfaces
Stacked listlayoutBelow --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

StateRendering
DefaultHeader sticky, rows striped with --ds-table-stripe
Sortedaria-sort on the active header, direction indicator beside the label
Row hover--ds-surface, only when rows are interactive
EmptyReplaced by an empty state of the right type, never a headerless blank
LoadingSkeleton 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> with aria-sort.
  • Row striping is decorative; row identity comes from the row header cell.

Keyboard

KeyAction
TabThrough sortable headers and interactive cells
Enter / SpaceToggle sort on a focused header

Tokens

--ds-table-header-bg · --ds-table-stripe · --ds-border · --ds-space-3 · --ds-space-4

Table
ContactAmountStatusDate
Kenya flagAdaeze Okafor
-250.00 USDCompletedApr 15
Ghana flagKwame Johnson
+1,200.00 GHSProcessingApr 14
United States flagCarlos Rivera
-800.00 USDFailedApr 13

List

Unordered list with primary-colored bullets.

  • Used for requirements, feature lists, and options
  • Bullet color uses --ds-primary for visual emphasis

Anatomy

A list container and its items. Nesting is permitted one level; deeper structure is a Tree view.

Variants

VariantDimensionElement
Unorderedtype<ul> — bullets in --ds-primary
Orderedtype<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

List
  • •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
VariantIconUsage
defaultNoneGeneral informational disclosure
regulatoryShieldCheckLicensing, compliance, legal notices
cryptoTriangleAlertRisk warnings for crypto/volatile assets
biometricFingerprintBiometric 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-muted is #636971 for 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

VariantDimensionIconUse
defaulttypenoneGeneral informational disclosure
regulatorytypeShieldCheckLicensing, compliance, legal notices
cryptotypeTriangleAlertRisk warnings for volatile assets
biometrictypeFingerprintBiometric 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

StateRendering
DefaultIcon, 12px --ds-muted text
With acknowledgementCheckbox with the full statement as its label
AcknowledgedCheckbox 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

KeyAction
TabInline links, then the acknowledgement checkbox
SpaceToggle the acknowledgement

Tokens

--ds-muted · --ds-text-xs · --ds-surface · --ds-radius-md

Disclosure

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-nums and 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

DoDon't
Right-align amounts with tabular-numsCenter a numeric column
Show "250.00 USD"Show "250.00" and rely on a flag
Stack a table into rows on mobileLet a financial table scroll horizontally
Give a clickable card a single controlPut two buttons inside a clickable card
Put decisions in a notice at body sizeBury 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.