Skip to main content
ENKO

Transfer components

Components built for Senco's cross-border transfer flow. These extend the base component library for the fintech use case.

This is the part of the system no component library ships. Each component here encodes a domain decision — what "sent" means versus "completed", how an amount is composed, what a receipt has to prove — and those decisions are the reason the DS exists rather than a theme over shadcn.

Components

ComponentPurposeBase
Status badgeDisplay transfer status (pending, sent, completed, failed)Badge
Amount inputEnter transfer amount with live currency conversionInput
Transaction rowSingle entry in the transaction history list—
Transfer stepperVertical multi-step progress for the transfer flowStepper
Camera captureDocument/selfie capture viewfinder for KYC—
QR scannerQR code scanner with scan-line animation—
Receipt viewTransfer receipt with tear-line and status headerCard
Contact cardTappable card showing contact avatar, name, country, and methodCard

Status badge

Communicates the current state of a transfer. Uses color, a dot, an icon, and a text label — never color alone, and never a dot alone. A dot is the same shape for all six statuses, so the dot reinforces the color and the icon is what carries meaning under a color vision deficiency.

StatusColorUsage
PendingYellowTransfer submitted, awaiting processing
SentBlueTransfer sent, in transit
CompletedGreenTransfer received by recipient
FailedRedTransfer could not be completed

Sent is not completed

This is the most important distinction in the product. Sent means the money left Senco. Completed means the recipient has it. In cross-border transfer those can be two days apart, and a payment that is "sent" can still fail at the receiving bank.

Green is reserved for completed. Using it for sent tells a user their family has the money when it may not arrive — and it is the single failure that would cost the most trust.

This is the canonical status table for the system. Status indicators describes when each status applies; this table defines what each one renders as. Where the two disagree, this table wins.

StatusTerminalBackgroundTextDot and iconIcon (lucide)LabelClass
PendingNo--ds-warning-bg--ds-warning-text--ds-warning-iconClockPending.snc-badge-pending
ProcessingNo--ds-info-bg--ds-info-text--ds-info-iconLoaderCircleProcessing.snc-badge-processing
SentNo--ds-info-bg--ds-info-text--ds-info-iconArrowUpRightSent.snc-badge-sent
CompletedYes--ds-success-bg--ds-success-text--ds-success-iconCheckCompleted.snc-badge-completed
FailedYes--ds-error-bg--ds-error-text--ds-error-iconTriangleAlertFailed.snc-badge-failed
CancelledYes--ds-surface--ds-muted--ds-mutedXCancelled.snc-badge-cancelled

What each one means to the user:

StatusMeaning
PendingWe have it, we haven't moved it yet
ProcessingThe network is working on it
SentIt has left us, the bank is handling it
CompletedThey have it
FailedIt did not go through; here is why
CancelledYou stopped it; the money is back

Three things in that table are deliberate and easy to get wrong:

  • Sent and Processing render identically. Same background, same text, same dot. They are distinguished only by icon and label — which is exactly what triple signaling is for, and it is why the icon is not optional here.
  • Every colour is a semantic token, so the badge needs nothing from the theme. Text uses the feedback set's text token; dot and icon use its icon token. The dark theme remaps all of them. An earlier version took text and dot from Base ramp steps, which dark mode by rule never remaps — the background went dark and the text did not, and dark-mode Pending measured 1.72:1.
  • Cancelled uses the neutral surface. No neutral feedback set exists, so it composes --ds-surface and --ds-muted, both remapped by the theme. Its chip edge against the page is faint (1.10:1 light, 1.22:1 dark). That is accepted on the same grounds as the feedback borders in Color: the icon and the label identify the status, not the chip's edge.

Verified contrast

Measured from the token values in both themes. Text needs 4.5:1; dot and icon need 3:1.

StatusText, lightDot and icon, lightText, darkDot and icon, dark
Pending8.17:14.64:19.77:17.72:1
Processing / Sent7.80:15.77:18.57:18.57:1
Completed6.68:14.70:19.74:17.34:1
Failed7.67:15.98:18.59:16.22:1
Cancelled5.04:15.04:15.71:15.71:1

Anatomy

Dot, icon, label, then an optional timestamp, in that order along the inline axis. Dot, icon, and label render in every size; the dot reinforces the colour and the icon carries the meaning.

PartCompactDefault
Label--ds-text-xs, weight 600--ds-text-sm, weight 600
Line height1.61.5
Padding3px block, 10px inline--ds-space-1 block, --ds-space-3 inline
Gap between parts6px--ds-space-2
Dot6px circle8px circle
Icon12px--ds-size-icon-sm (16px)
Radius--ds-radius-full--ds-radius-full
TimestampNot rendered — the containing row shows it--ds-text-xs, --ds-muted weight 400
Wrappingwhite-space: nowrapwhite-space: nowrap

The compact padding (3px, 10px) and gap (6px) sit off the 4px spacing scale. They are the values that fit a 60px transaction row, and they are recorded in Known gaps rather than rounded to a scale step that would not fit.

The Processing icon does not rotate inside a badge. A badge reports a transfer's state; it is not a loading indicator for something the user is waiting on, and a list of rotating icons is motion with no information in it.

Variants

VariantDimensionUse
CompactlayoutTransaction list row — dot, icon, label
DefaultlayoutTransaction detail header — dot, icon, label, timestamp

API

type TransferStatus =
  | "pending" | "processing" | "sent"      // non-terminal
  | "completed" | "failed" | "cancelled";  // terminal

interface StatusBadgeProps {
  status: TransferStatus;
  since?: Date;                     // entered-this-status timestamp; renders "Pending since 10:42"
  size?: "compact" | "default";     // default: "compact" — compact is the list-row form
  announce?: boolean;               // default: false — true only where this badge owns the live announcement
  subject?: string;                 // required when announce is true — "Transfer to Maria Santos"
}
PropContract
statusThe union is closed. There is no "unknown" — a status the backend cannot classify is pending
sinceRequired for every non-terminal status, at either size — "Pending" with no age cannot be distinguished from "stuck". The default size renders it inside the badge. The compact size does not; it is only used inside a container that renders the same time, such as the Transaction row's meta line. Format: Typography — Pending since 10:42, Completed · 12 Mar, 10:42
announceThe badge and its parent row must not both announce. In a transaction list the row owns the announcement; on a detail screen the badge does
subjectThe announcement names the transfer: "Transfer to Maria Santos is now completed". "Completed" alone, in a list of six transfers, says nothing about which one

Announcements are polite, except a change to failed, which is assertive. A failure is the one status change that alters what the user should do next.

Rendering comes from the canonical status table above. The component holds no colour logic of its own.

States

The six statuses above are the states. A badge has no hover, focus, or pressed state — it is not a control. since renders inside it; the value updates in place without re-announcing.

Behavior

Renders from the canonical table and holds no colour logic. since updates in place without re-announcing. A status change announces only when announce is set, so a row and its badge never both speak.

Content

Sentence case, one word where possible: "Completed", not "COMPLETED" and not "Successfully completed".

Accessibility

Triple signaling is mandatory: color, icon, and text label. Under deuteranopia the pending yellow and completed green converge — the icon and the word are what survive. See Status indicators.

Keyboard

Not focusable and not interactive. The row or detail view that contains it owns the interaction.

Tokens

--ds-warning-bg · -text · -icon · --ds-info-* · --ds-success-* · --ds-error-* · --ds-surface · --ds-muted · --ds-radius-full · --ds-text-xs · --ds-text-sm — rendered through .snc-badge and .snc-badge-{status}

Status badge
PendingSentCompletedFailed

Amount input

Combines a large numeric input with a currency tag. Shows live conversion rate below.

  • Input accepts numbers only with auto-formatting
  • Currency tag is tappable to switch currencies
  • Conversion rate updates in real time
  • Supports both fiat and crypto amounts

Anatomy

┌──────────────────────────────────────┐
│  You send                            │  label, --ds-text-sm
│  ┌────────────────────┐  ┌────────┐  │
│  │  1,250.00          │  │ 🇺🇸 USD ▾│  │  --ds-text-display / 700
│  └────────────────────┘  └────────┘  │  currency tag, tappable
│  ≈ 2,062,500.00 NGN                  │  --ds-text-sm, --ds-muted
│  Rate 1 USD = 1,650.00 NGN           │  --ds-text-xs, --ds-muted
└──────────────────────────────────────┘
PartToken
Field.snc-amount-field, --ds-input-height
Amount--ds-text-display, weight 700, tabular-nums
Currency tag.snc-amount-currency-tag, 48px target
Conversion.snc-amount-conversion, --ds-muted

The editable value is a single text run at one size. The cents step-down in Typography applies to amounts that are displayed, not to an amount being typed — an <input> cannot render two sizes.

Variants

VariantDimensionUse
HerolayoutSend screen primary amount — --ds-text-display, weight 700, line height 1.2, field min-height 64px, --ds-space-2 block padding
DefaultlayoutSecondary amount fields

API

interface CurrencyOption {
  code: string;                     // ISO 4217, or a product-owned code for crypto
  kind: "fiat" | "crypto";          // decides minor-unit source and whether a flag is expected
  name: string;                     // "Nigerian Naira" — also the spoken form in descriptions
  minorUnits: number;               // 2 USD, 0 JPY, 8 BTC — see Tokens > Currency minor units
  flag?: string;                    // ISO 3166-1 alpha-2; omitted for crypto
  keywords?: string[];              // extra search terms — country names, "naira"
  rateLabel?: string;               // pre-formatted, shown in the picker — "1 USD = 1,650.00"
}

interface RateQuote {
  rate: number;
  quotedAt: Date;
  expiresAt: Date;                  // quotedAt + 60s fiat, + 15s crypto
}

interface AmountInputProps extends FieldBase {   // label, hint, error, required, disabled, readOnly, name, onBlur
  value: string;                    // string, not number: a trailing "." must survive a render
  onValueChange: (value: string) => void;
  currency: CurrencyOption;
  currencies: CurrencyOption[];
  suggestedCurrencies?: string[];   // codes, rendered first in the picker — recent and likely corridors
  onCurrencyChange: (currency: CurrencyOption) => void;
  targetCurrency?: CurrencyOption;  // omit to hide the conversion line
  quote?: RateQuote;
  quoteState?: "idle" | "loading" | "live" | "expiring" | "expired" | "error";  // default: derived — see below
  onRefreshQuote?: () => void;
  max?: string;                     // balance or limit; exceeding it errors without clearing the value
  size?: "default" | "hero";        // default: "hero" on the send screen
  locale?: string;                  // default: the app locale — drives separators and decimal mark
}
PropContract
valueNever cleared by the component. A validation failure sets error and leaves the value alone. Format: a canonical decimal string — ASCII digits, at most one ., at most minorUnits digits after it, no grouping separators, no symbol ("1250.5"). Formatting is display-only. max uses the same format, in the same currency as currency
quoteExpiry is a rendered state, not a silent swap. At expiresAt the conversion strikes through and onRefreshQuote surfaces
quoteStateWhen omitted it is derived: no targetCurrency or empty value → idle; quote absent → loading; now before the halfway point between quotedAt and expiresAt → live; after it → expiring; at or past expiresAt → expired. error is never derived: the caller owns the quote request and its 10s timeout (Tokens > Timing and threshold values) and sets quoteState="error" when it fails or times out
FieldBaseAmount input takes part in a form like every other field: name for submission, disabled and readOnly with the universal meanings, onBlur for validation timing
size"hero" here and the hero size of Currency input are the same surface. Amount input is the one the send screen uses; Currency input's hero is for a single-currency receive field

Caret preservation

The field reformats as the user types, and the caret must not jump. The contract, stated because two implementations will otherwise differ on exactly the three cases users hit:

  1. Count the digits before the caret, ignoring separators.
  2. Reformat the whole value.
  3. Place the caret after the same digit index, restoring in a layout effect so no intermediate position paints.
CaseRequired behaviour
Backspace over a separatorDeletes the digit before the separator, not the separator
PasteKeeps digits and the first decimal mark (the locale's separator, or .); strips everything else; truncates to minorUnits; places the caret at the end. "$1,250.50" becomes 1250.50
Select-all then typeReplaces the whole value; caret at the end

States

StateRendering
EmptyConversion line reads "Enter an amount to see the {code} equivalent"
TypingValue reformats; caret preserved per the contract above
Quote loadingConversion line shows inline loading
Quote liveConverted figure plus the rate line
Quote expiringRemaining validity shown from the halfway mark
Quote expiredConverted figure struck through, refresh action surfaced
Quote errorRate line replaced by a retry action; the entered amount is untouched
Over maxInline error, value retained

Behavior

  • Input is inputmode="decimal", not type="number" — a number input shows spinner arrows, accepts e and +, and behaves differently in every browser.
  • Grouping separators are inserted as the user types; the caret position is preserved across the reformat.
  • Precision follows the currency's minor unit: 2 for USD, 0 for JPY, 8 for BTC. A crypto amount and a fiat amount are not the same field with a different label.
  • The conversion line updates on a debounce, never on every keystroke.
  • Crossing the balance or a limit shows the error inline and keeps the entered value. Clearing a user's input because it was too large is how a user loses the amount they had just worked out.

Currency picker

Tapping the currency tag opens a Sheet. It is the only picker this component uses.

────────────────────────────────────────
  Choose currency                    ✕
  🔍 Search currency or country
  Suggested
   🇳🇬  NGN   Nigerian Naira    1 USD = 1,650.00
   🇵🇭  PHP   Philippine Peso   1 USD = 56.20
  All currencies
   🇦🇪  AED   UAE Dirham        1 USD = 3.67
   …
────────────────────────────────────────
PartSpecification
ContainerSheet, side="bottom" below --ds-bp-mobile, side="end" at and above it. Size lg on mobile, default on desktop
Title"Choose currency" — the Sheet's required accessible name
SearchInput at the top. Matches code, name, and keywords, case-insensitive. Its label is visually hidden and matches the placeholder — see Copy. Auto-focused on pointer devices only — on touch, focusing it raises the keyboard over the list the user came to scan
SuggestedsuggestedCurrencies in the order given. Hidden while a search query is present
All currenciesEvery entry in currencies, alphabetical by code
RowFlag (decorative, 28px) · code --ds-text-base 600 · name --ds-text-sm --ds-muted · rateLabel inline-end --ds-text-sm tabular-nums. Height --ds-size-list-item (60px)
Selected rowCheck icon inline-end in --ds-primary, aria-selected="true"
No results"No currency matches '{query}'" with the query shown, inside the list area
BehaviourRule
Selecting a rowCloses the sheet, calls onCurrencyChange, returns focus to the currency tag. The entered amount is kept and reformatted to the new minorUnits
After a changeonCurrencyChange fires; the caller clears quote and requests a new one. With quote absent the derived state is loading
Dismissing without selectingEscape, the close control, the backdrop, or a downward swipe. The currency is unchanged
ReopeningSearch is cleared and the list scrolls to the selected row
KeyAction
Enter / Space on the currency tagOpen the sheet
TypingGoes to the search input
↓ from searchMove into the list
↑ ↓Move the active row
EnterSelect the active row
EscClose without changing

The list uses the listbox pattern inside the dialog: role="listbox" on the list, role="option" on rows, aria-activedescendant from the search input. The result count is announced politely as the query changes.

Structure: one listbox containing two role="group" elements labelled "Suggested" and "All currencies"; only the second while a query is present. ↑ ↓ do not wrap, and Home / End go to the first and last row. When the sheet opens, the active row is the selected currency. The active row takes --ds-surface; the selected row also shows the Check icon.

Rate freshness

A quoted rate has a validity window. When it expires the conversion line is struck through and a refresh action appears; the stale number is never shown as current. If the rate changes materially between quote and confirmation, the confirm step re-quotes and states the change rather than silently charging the new rate.

Content

The label says what the amount is for — "You send", "They receive" — never "Amount".

Copy

MomentText
Picker search label (visually hidden) and placeholderSearch currency or country
Picker result count, announced{n} currencies
Picker, no resultsNo currency matches "{query}"
Empty conversion lineEnter an amount to see the {code} equivalent
Quote loadingGetting today's rate
Quote expiringRate held for {s}s
Quote expired, with actionRate expired · Refresh rate
Quote error, with actionCouldn't get a rate · Try again
Over maxYou can send up to {max} {code}
Rate update, announcedRate updated: 1 {from} = {rate} {to}

Accessibility

  • Visible label above the field — never a placeholder as the label.
  • The conversion line and rate are attached via aria-describedby, so they are read with the field.
  • Rate updates are announced politely, throttled. A rate refreshing every 30 seconds on an assertive region would interrupt the user continuously.
  • The currency tag is a <button>. Its visible text is the code ("USD"); a visually hidden suffix completes the name — "USD, US Dollar, change currency" — so the accessible name contains the visible text (SC 2.5.3). It carries aria-haspopup="dialog" and aria-expanded while the picker is open.
  • The field's value is read as typed — a text input's value cannot be given a different spoken form. The currency is carried by the field's description instead. aria-describedby references, in order: the error if present, the currency's name, the conversion line, the rate line, then the hint.
  • The conversion line renders its visible short form (≈ 2,062,500.00 NGN) as aria-hidden and a visually hidden spoken form beside it ("approximately 2,062,500.00 Nigerian Naira"), because "≈" and currency codes are read inconsistently across screen readers.

What VoiceOver reads on focus:

You send, 1,250.00, text field, US Dollar, approximately 2,062,500.00 Nigerian Naira, rate 1 US Dollar equals 1,650.00 Nigerian Naira

The spoken-currency rule for amounts that are displayed rather than typed — balances, receipts, fee rows — is in Typography.

Keyboard

KeyAction
TabAmount field, then the currency tag
Enter / Space on the currency tagOpen the currency picker
Digits and the locale decimal markAccepted (. is always accepted as an alias); other characters rejected without clearing
Ctrl/Cmd+VNormalised per the paste rule, caret to the end

Tokens

.snc-amount-input · .snc-amount-field · .snc-amount-currency-tag · .snc-amount-conversion · .snc-currency-code · .snc-currency-flag

Amount input
$

C$10 CAD — C$10,000 CAD

Transaction row

Displays a single transfer in the mobile transaction history. Shows recipient avatar, name, date, amount, currency, and status badge.

  • Rows are tappable to view transfer details
  • Amount is right-aligned for scannability
  • Status badge is always visible without scrolling

Anatomy

┌────────────────────────────────────────────┐
│ (🇳🇬)  Maria Santos          -250.00 USD    │  60px min-height
│       Bank transfer · 12 Mar   [Completed]  │
└────────────────────────────────────────────┘
   36px      14px/600 name        16px/700 amount
   flag      12px/400 meta        status badge
PartToken
Row height--ds-size-list-item (60px)
Padding--ds-space-3 block, --ds-space-4 inline
Flag--ds-size-avatar (36px)
Name.snc-tx-name, --ds-text-sm / 600
Meta.snc-tx-meta, --ds-text-xs, --ds-muted
Amount.snc-tx-amount, --ds-text-base / 700, tabular-nums

Variants

VariantDimensionUse
DefaultlayoutMobile transaction history
DenselayoutDashboard "recent activity", 5 rows, no method line

API

interface TransactionRowProps {
  recipientName: string;
  countryCode: string;              // ISO 3166-1 alpha-2 — renders the flag, decorative
  method: string;                   // "Bank transfer", "Mobile money"
  amount: string;                   // pre-formatted, including the currency code
  direction: "out" | "in";
  status: TransferStatus;
  date: Date;
  href: string;                     // the row is one link; there is no second action inside it
}

States

StateRendering
Default--ds-bg
Hover--ds-surface
Focus-visible3px solid --ds-focus outline, inset
Pressed--ds-surface, no elevation change

On hover and press the row background equals a Cancelled badge's background, so that chip's edge disappears (1.00:1). This is accepted: the badge's icon and label stay at 5.04:1 against the same surface, and they — not the chip's edge — carry the status.

Behavior

  • The whole row is one control. There is no secondary action inside it — a row with a tappable region and a non-tappable region is a row users tap in the wrong place.
  • The amount carries its currency code and its direction. A bare number in a multi-currency history is unreadable.
  • Status is visible without expanding. The reason this row exists is to answer "did it arrive?".
  • Under Save-Data or offline the flag falls back to a country-code circle. The row loses nothing that matters.

Content

The amount carries a sign and a currency code. The meta line is method and date, in that order — the method is what a user checks when a transfer is slow.

Accessibility

  • Rendered as a <button> or <a> wrapping the row content, with one accessible name that reads as a sentence: "Maria Santos, 250 dollars, sent 12 March, completed."
  • The flag is decorative (alt="") because the country is already in the accessible name.
  • The status badge is part of the row's name, not a separately focusable element.
  • 60px height exceeds the 48px touch minimum.

Keyboard

KeyAction
TabOne stop for the whole row
EnterOpen the transfer detail

Tokens

.snc-tx-row · .snc-tx-avatar · .snc-tx-name · .snc-tx-meta · .snc-tx-amount · .snc-tx-currency · .snc-tx-details · .snc-tx-value

Transaction row
Yuki Tanaka flag
Yuki Tanaka
Mar 27 · Sent
-250.00 USD
Completed
James Wilson flag
James Wilson
Mar 27 · Sent
-120.00 USD
Completed
Priya Sharma flag
Priya Sharma
Mar 26 · Sent
-500.00 USD
Pending
Carlos Rivera flag
Carlos Rivera
Mar 25 · Received
+80.00 EUR
Failed

Transfer stepper

Vertical step indicator for the transfer flow. Shows completed, active, and upcoming steps with connecting lines.

  • Steps: Send → Review → Confirm
  • Completed steps show a check icon
  • Active step is highlighted with primary color
  • Upcoming steps are muted

Anatomy

Dot, connecting line, label, optional timestamp per step. Completed dots are 24px with a check; active and future dots are 12px.

Variants

VariantDimensionUse
HorizontallayoutTop of the transfer flow
VerticallayoutTransaction detail timeline, with timestamps

API

interface TransferStepperProps {
  steps: Array<{ id: string; label: string }>;   // 3 maximum
  currentIndex: number;
  completedIndices: number[];
  errorIndex?: number;
  onStepSelect?: (index: number) => void;        // called only for completed steps
  orientation?: "horizontal" | "vertical";       // default: "horizontal"
}

Upcoming steps are aria-disabled and are never passed to onStepSelect. The component does not advance itself.

States

StateDotLineToken
CompletedGreen fill, check iconGreen--ds-timeline-completed-dot
Active--ds-primary fill, pulsingDefault gray--ds-timeline-active-dot, --ds-timeline-pulse-color
Upcoming--ds-gray-300 outlineDefault gray--ds-timeline-future-dot

The active dot pulses. Under prefers-reduced-motion the pulse stops and the state is still carried by fill color, the icon, and the step label — motion is never the only signal.

Behavior

  • Completed steps are tappable and return to that step with data intact. Upcoming steps are not reachable.
  • The stepper never advances on its own. Only a user action moves it forward.
  • Three steps is the ceiling. A fourth step means the flow needs redesigning, not a longer stepper — see Design principles.

Content

Step labels are one or two words: "Send", "Review", "Confirm". Timestamps in the vertical variant are absolute, not relative — "14:32, 12 Mar", not "2 hours ago".

Accessibility

  • Ordered list, so position and count are structural.
  • aria-current="step" on the active step.
  • Upcoming steps are aria-disabled, not removed from the tab order.

Keyboard

KeyAction
TabOne stop for the stepper
← →Between reachable steps
EnterReturn to the focused completed step

Tokens

--ds-timeline-completed-dot · --ds-timeline-completed-icon · --ds-timeline-active-dot · --ds-timeline-future-dot · --ds-timeline-line-completed · --ds-timeline-line-default · --ds-timeline-pulse-color

Transfer stepper

Camera capture

Visual KYC viewfinder component with two variants: document (ID card frame with corner marks) and selfie (circular frame). Designed as a showcase pattern — no native camera API.

  • Document variant: rectangular frame with 856:540 aspect ratio (standard ID) and L-shaped corner marks
  • Selfie variant: circular frame (160px diameter) with round border
  • Shutter button: 64px double-circle pattern (outer ring + inner fill)
  • Captured state switches to "Use Photo" and "Retake" buttons
  • Self-contained dark UI — no dark mode override needed

Why the chrome is fixed dark

Camera surfaces use --ds-camera-bg (#111827) in both themes. A viewfinder that inverts in light mode costs the user their frame of reference and gains nothing — the subject of the screen is the camera feed, not the interface. This is one of two sanctioned exceptions to theme remapping, recorded in Tokens.

Anatomy

Fixed-dark surface, frame overlay, hint text above the shutter, 64px double-circle shutter, manual upload link below.

Variants

VariantDimensionFrame
documenttype856:540 rectangle with L-shaped corner marks
selfietype160px circle

API

interface CameraCaptureProps {
  variant: "document" | "selfie";
  captured?: string;                // data URL or object URL; presence switches to the review state
  onCapture: () => void;
  onRetake: () => void;
  onConfirm: () => void;
  onManualUpload: () => void;       // required — a capture-only flow excludes users who cannot operate a camera
  hint?: string;                    // shown before capture, not after rejection
}

States

StateRendering
FramingViewfinder, frame marks, hint text
AlignedFrame marks turn --ds-camera-frame-active
CapturedStill image, "Use photo" and "Retake", neither pre-selected
UnavailableManual upload path, with the reason stated

Behavior

  • The frame matches the document shape (856:540 for an ID card, circular for a selfie). A capture frame that does not match the document produces uploads that verification rejects.
  • The captured state is reviewable before submission. "Use photo" and "Retake" are both present, and neither is pre-selected.
  • Guidance ("Fit your ID inside the frame") is visible before capture, not surfaced as an error after rejection.

Content

The hint says what a good capture looks like — "Fit your ID inside the frame, all four corners visible" — before capture, not as an error afterwards.

Accessibility

  • The shutter button has a variant-specific aria-label — "Take photo of ID", "Take selfie" — not "Capture".
  • Frame corner marks are decorative and aria-hidden.
  • Capture success is announced politely.
  • A manual upload path exists for users who cannot operate a camera. A capture-only KYC flow excludes people with motor or vision impairments from the product entirely.

Keyboard

KeyAction
TabShutter, then manual upload
Enter / SpaceCapture

Tokens

--ds-camera-bg · --ds-camera-overlay · --ds-camera-frame · --ds-camera-frame-active · --ds-camera-capture-bg · --ds-camera-capture-border · --ds-camera-retake-bg · --ds-camera-hint

Camera capture
Document
Position your ID within the frame
Selfie
Center your face in the circle

QR scanner

QR code scanner viewfinder with animated scan line. Shows scanning and success states. Includes optional manual entry link.

  • Square scan window: 65% parent width, max 280px
  • 4 L-shaped corner marks (28px)
  • Animated scan line moves top-to-bottom repeatedly
  • Success state: green check icon replaces scan line, corners turn green
  • Manual entry button with keyboard icon
  • Self-contained dark UI — no dark mode override needed

Manual entry is required, not optional

The manual entry link is part of the component, not a fallback. Scanning fails on cracked screens, in low light, with a damaged printed code, and for anyone who cannot hold a phone steady — all common in Senco's audience. A scanner without a typed alternative is a dead end.

Anatomy

Fixed-dark surface, square scan window at 65% width capped at 280px, four 28px corner marks, animated line, hint, manual entry button.

Variants

One. A scanner that cannot fall back to typing is not a variant, it is a defect.

API

interface QRScannerProps {
  state: "scanning" | "success" | "error";
  onScan: (value: string) => void;
  onManualEntry: () => void;        // required, not a fallback
  hint?: string;
  errorMessage?: string;
}

States

StateRendering
ScanningScan line loops; corner marks neutral
SuccessCorner marks green, check icon replaces the line, announced
ErrorMessage plus retry; manual entry stays available
Reduced motionScan line static; scanning and announcement unchanged

Behavior

  • The scan line loops continuously while scanning and stops on success.
  • Success turns the corner marks green and shows a check icon and announces. Color alone does not confirm.
  • Under prefers-reduced-motion the scan line is static; scanning still works and is announced.

Content

The instruction names what to point at. The manual entry control says what it does — "Enter the address instead" — never "Having trouble?".

Accessibility

  • The scanner region has an accessible name and a text instruction.
  • Success and failure are announced through a polite live region.
  • The manual entry control is a real button with a 48px target.
  • The animated line is decorative and aria-hidden.

Keyboard

KeyAction
TabManual entry control
EnterOpen manual entry

Tokens

--ds-qr-bg · --ds-qr-overlay · --ds-qr-frame · --ds-qr-scan-line · --ds-qr-hint · --ds-qr-manual-link

QR scanner
Scanning
Point your camera at a QR code
Success
QR code detected

Receipt view

Compound component displaying transfer receipts with status header, tear-line separator, key-value rows, and action footer.

Sub-components:

  • ReceiptView — root container with status header
  • ReceiptViewSection — grouped rows with optional title
  • ReceiptViewRow — label + value pair using dl/dt/dd semantics
  • ReceiptViewActions — footer with action buttons

Structure:

  • Header: status icon + title + subtitle (date)
  • Tear line: CSS radial-gradient scalloped edge
  • Body: key-value rows in sections
  • Footer: download/share buttons

What a receipt must contain

A receipt is the artifact a user forwards to their bank, their family, or support. It is the only screen in the product that has to be complete out of context:

FieldWhy
Reference numberThe only thing support can search on
Status and timestampWhat state it was in, and when
Sent amount + currencyWhat left
Received amount + currencyWhat arrived
Exchange rate appliedThe rate at execution, not the current rate
Fees, itemizedTransfer fee and FX margin, separately
Sender and recipientNames as they appear on the accounts
Payment methodWhich rail was used

The rate shown is the rate that was applied, permanently. A receipt that re-renders with today's rate is not a receipt.

Anatomy

Status header with icon, title, and date; a scalloped tear line; body sections of label-value rows; a footer with download and share.

Variants

VariantDimensionUse
screentypeIn-app, system font, actions present
exporttypePDF or email, Arial stack, no actions

API

interface ReceiptViewProps {
  status: TransferStatus;
  reference: string;                // the only value support can search on
  timestamp: Date;
  sections: Array<{
    title?: string;
    rows: Array<{ label: string; value: string; emphasis?: boolean }>;
  }>;
  onDownload?: () => void;
  onShare?: () => void;
  rendering?: "screen" | "export";  // default: "screen"; "export" switches to the documentation font stack
}

Values are pre-formatted strings, and the rate shown is the rate that was applied. A receipt that re-derives its rate at render time is not a receipt.

States

StateRendering
CompletedGreen status header with a check
Pending / SentStatus header in the matching feedback set
FailedError header plus the reason, and the timeline stops where it failed
ExportDocumentation font stack, no interactive controls

Behavior

  • Exported receipts use the documentation font stack (Arial), not system-ui, so a PDF renders identically everywhere. See Typography.
  • The tear line is a CSS gradient, not an image. The receipt renders completely with zero images loaded.
  • Download and share are separate actions — share invokes the OS sheet, download writes a file.

Content

Every row in the Receipt contents table above is required. A receipt missing its reference number cannot be used with support, which is most of what a receipt is for.

Accessibility

  • Semantic <article> root with <dl> / <dt> / <dd> for key-value pairs, so a screen reader pairs each label with its value instead of reading two unrelated columns.
  • The tear line is decorative and aria-hidden.
  • Amounts use tabular-nums and are announced with their currency.
  • The status icon is aria-hidden; status is text in the header.

Keyboard

KeyAction
TabDownload, then share
EnterActivate

Tokens

--ds-receipt-bg · --ds-receipt-border · --ds-receipt-header-bg · --ds-receipt-icon-success · --ds-receipt-label · --ds-receipt-value · --ds-receipt-divider · --ds-receipt-footer-bg · --ds-receipt-tear

Receipt view

From

Canada flag
Devon Kim
Canada
Visa
····8293

To

South Korea flag
Seo-yeon Park
South Korea
Bank transfer
····4521
SNC-2026-0847Completed
You sent$150.00 CAD
Service fee (%)−$2.25
Rate986.76 KRW
Seo-yeon received₩145,794.49

Contact card

Tappable card displaying a contact's avatar, name, country flag, payment method, and optional detail. Used in the recipient selection grid on the Send start screen.

Visual structure

  • Vertical flex layout, center-aligned
  • Avatar at top with initials fallback
  • Name, payment method with country flag, optional detail
  • Hover: background and shadow transition

Anatomy

Avatar or initials at the top, name, method with country flag, optional masked detail. Centre-aligned in the grid variant.

Variants

VariantDimensionUse
GridlayoutRecipient selection on the send start screen
RowlayoutContact list

API

interface ContactCardProps {
  name: string;
  countryCode: string;
  method: string;                   // selecting a contact selects a contact *and* a rail
  detail?: string;                  // masked account fragment
  avatarUrl?: string;               // falls back to initials — the normal case under Save-Data
  selected?: boolean;
  onSelect: () => void;
}

States

StateVisualToken
Default--ds-contactcard-bg, --ds-contactcard-border—
HoverBackground and shadow raise--ds-contactcard-hover-bg, --ds-contactcard-hover-shadow
Focus-visibleFocus ring--ds-focus-ring
Selected--ds-primary border, --ds-primary-light fill—

Hover raises the shadow and never changes the border. Border change means selected, and the two states have to stay distinguishable — a user picking a recipient for a payment must be certain which one is chosen.

Behavior

  • The avatar falls back to initials when no image loads, which is the normal case under Save-Data.
  • The payment method is shown on the card, because the same person can be a recipient by bank transfer and by crypto with different fees and arrival times. Selecting a contact selects a contact and a rail.
  • Selecting a card advances the flow immediately. It is a choice, not a form field awaiting submit.

Content

The method is shown, not implied. The same person by bank transfer and by mobile money are two different choices with different fees.

Accessibility

  • Rendered as a <button> with an accessible name covering everything visible: "Maria Santos, Nigeria, bank transfer."
  • The flag is decorative; the country is in the name.
  • 48px minimum target; the card is considerably larger.
  • Selection state is conveyed by aria-pressed or by radio semantics when the grid is a single-choice group.

Keyboard

KeyAction
TabOne stop for the card
Enter / SpaceSelect and advance

Tokens

--ds-contactcard-bg · --ds-contactcard-border · --ds-contactcard-hover-bg · --ds-contactcard-hover-shadow

Contact card
Japan flag
Yuki Tanaka
Japan
United Kingdom flag
James Wilson
United Kingdom
South Korea flag
Min-ji Kim
South Korea
China flag
Aisha Okafor
China

Design and accessibility

  • Status badges use color + dot + text — accessible to color-blind users
  • Amount input has visible label and aria-describedby for conversion rate
  • Transaction rows are keyboard-focusable with enter to view details
  • Touch targets are 48px minimum on all interactive elements
  • Camera capture shutter button has descriptive aria-label per variant
  • QR scanner scan-line animation respects prefers-reduced-motion: reduce
  • Receipt view uses semantic article root, dl/dt/dd for key-value pairs
  • Contact card uses <button> element for full keyboard accessibility

Rules specific to money components

  • Every amount states its currency. No exceptions anywhere on this page.
  • Sent is not completed. Green is reserved for money the recipient has.
  • A quoted rate carries its validity. Stale rates are struck through, never shown as current.
  • Every scan or capture has a typed alternative. A camera-only path excludes users the product must serve.
  • Amounts use tabular-nums so columns align and an outlier is visible.
  • A user's entered amount is never cleared by a validation error.

Do and don't

DoDon't
Mark a transfer "Sent" until the recipient has itShow green the moment the money leaves
Show "250.00 USD → 412,500.00 NGN"Show "250.00 → 412,500.00"
Offer manual entry beside the QR scannerShip scan-only recipient entry
Keep the applied rate on the receipt foreverRe-render a receipt with today's rate
Use inputmode="decimal" for amountsUse type="number" with spinner arrows
Let the whole transaction row be one controlAdd a second button inside the row

Known gaps

  • Camera capture is a visual showcase with no native camera integration.
  • Crypto precision (8 decimals) is specified for Amount input but no crypto-specific component variant is documented.
  • No component here documents its offline state explicitly, though the patterns describe them.
  • .snc-* classes are the implementation surface; several have no corresponding --ds- token.