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
| Component | Purpose | Base |
|---|---|---|
| Status badge | Display transfer status (pending, sent, completed, failed) | Badge |
| Amount input | Enter transfer amount with live currency conversion | Input |
| Transaction row | Single entry in the transaction history list | — |
| Transfer stepper | Vertical multi-step progress for the transfer flow | Stepper |
| Camera capture | Document/selfie capture viewfinder for KYC | — |
| QR scanner | QR code scanner with scan-line animation | — |
| Receipt view | Transfer receipt with tear-line and status header | Card |
| Contact card | Tappable card showing contact avatar, name, country, and method | Card |
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.
| Status | Color | Usage |
|---|---|---|
| Pending | Yellow | Transfer submitted, awaiting processing |
| Sent | Blue | Transfer sent, in transit |
| Completed | Green | Transfer received by recipient |
| Failed | Red | Transfer 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.
| Status | Terminal | Background | Text | Dot and icon | Icon (lucide) | Label | Class |
|---|---|---|---|---|---|---|---|
| Pending | No | --ds-warning-bg | --ds-warning-text | --ds-warning-icon | Clock | Pending | .snc-badge-pending |
| Processing | No | --ds-info-bg | --ds-info-text | --ds-info-icon | LoaderCircle | Processing | .snc-badge-processing |
| Sent | No | --ds-info-bg | --ds-info-text | --ds-info-icon | ArrowUpRight | Sent | .snc-badge-sent |
| Completed | Yes | --ds-success-bg | --ds-success-text | --ds-success-icon | Check | Completed | .snc-badge-completed |
| Failed | Yes | --ds-error-bg | --ds-error-text | --ds-error-icon | TriangleAlert | Failed | .snc-badge-failed |
| Cancelled | Yes | --ds-surface | --ds-muted | --ds-muted | X | Cancelled | .snc-badge-cancelled |
What each one means to the user:
| Status | Meaning |
|---|---|
| Pending | We have it, we haven't moved it yet |
| Processing | The network is working on it |
| Sent | It has left us, the bank is handling it |
| Completed | They have it |
| Failed | It did not go through; here is why |
| Cancelled | You 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-surfaceand--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.
| Status | Text, light | Dot and icon, light | Text, dark | Dot and icon, dark |
|---|---|---|---|---|
| Pending | 8.17:1 | 4.64:1 | 9.77:1 | 7.72:1 |
| Processing / Sent | 7.80:1 | 5.77:1 | 8.57:1 | 8.57:1 |
| Completed | 6.68:1 | 4.70:1 | 9.74:1 | 7.34:1 |
| Failed | 7.67:1 | 5.98:1 | 8.59:1 | 6.22:1 |
| Cancelled | 5.04:1 | 5.04:1 | 5.71:1 | 5.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.
| Part | Compact | Default |
|---|---|---|
| Label | --ds-text-xs, weight 600 | --ds-text-sm, weight 600 |
| Line height | 1.6 | 1.5 |
| Padding | 3px block, 10px inline | --ds-space-1 block, --ds-space-3 inline |
| Gap between parts | 6px | --ds-space-2 |
| Dot | 6px circle | 8px circle |
| Icon | 12px | --ds-size-icon-sm (16px) |
| Radius | --ds-radius-full | --ds-radius-full |
| Timestamp | Not rendered — the containing row shows it | --ds-text-xs, --ds-muted weight 400 |
| Wrapping | white-space: nowrap | white-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
| Variant | Dimension | Use |
|---|---|---|
| Compact | layout | Transaction list row — dot, icon, label |
| Default | layout | Transaction 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"
}
| Prop | Contract |
|---|---|
status | The union is closed. There is no "unknown" — a status the backend cannot classify is pending |
since | Required 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 |
announce | The 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 |
subject | The 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}
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
└──────────────────────────────────────┘
| Part | Token |
|---|---|
| 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
| Variant | Dimension | Use |
|---|---|---|
| Hero | layout | Send screen primary amount — --ds-text-display, weight 700, line height 1.2, field min-height 64px, --ds-space-2 block padding |
| Default | layout | Secondary 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
}
| Prop | Contract |
|---|---|
value | Never 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 |
quote | Expiry is a rendered state, not a silent swap. At expiresAt the conversion strikes through and onRefreshQuote surfaces |
quoteState | When 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 |
FieldBase | Amount 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:
- Count the digits before the caret, ignoring separators.
- Reformat the whole value.
- Place the caret after the same digit index, restoring in a layout effect so no intermediate position paints.
| Case | Required behaviour |
|---|---|
| Backspace over a separator | Deletes the digit before the separator, not the separator |
| Paste | Keeps 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 type | Replaces the whole value; caret at the end |
States
| State | Rendering |
|---|---|
| Empty | Conversion line reads "Enter an amount to see the {code} equivalent" |
| Typing | Value reformats; caret preserved per the contract above |
| Quote loading | Conversion line shows inline loading |
| Quote live | Converted figure plus the rate line |
| Quote expiring | Remaining validity shown from the halfway mark |
| Quote expired | Converted figure struck through, refresh action surfaced |
| Quote error | Rate line replaced by a retry action; the entered amount is untouched |
Over max | Inline error, value retained |
Behavior
- Input is
inputmode="decimal", nottype="number"— a number input shows spinner arrows, acceptseand+, 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
…
────────────────────────────────────────
| Part | Specification |
|---|---|
| Container | Sheet, 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 |
| Search | Input 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 |
| Suggested | suggestedCurrencies in the order given. Hidden while a search query is present |
| All currencies | Every entry in currencies, alphabetical by code |
| Row | Flag (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 row | Check icon inline-end in --ds-primary, aria-selected="true" |
| No results | "No currency matches '{query}'" with the query shown, inside the list area |
| Behaviour | Rule |
|---|---|
| Selecting a row | Closes the sheet, calls onCurrencyChange, returns focus to the currency tag. The entered amount is kept and reformatted to the new minorUnits |
| After a change | onCurrencyChange fires; the caller clears quote and requests a new one. With quote absent the derived state is loading |
| Dismissing without selecting | Escape, the close control, the backdrop, or a downward swipe. The currency is unchanged |
| Reopening | Search is cleared and the list scrolls to the selected row |
| Key | Action |
|---|---|
Enter / Space on the currency tag | Open the sheet |
| Typing | Goes to the search input |
↓ from search | Move into the list |
↑ ↓ | Move the active row |
Enter | Select the active row |
Esc | Close 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
| Moment | Text |
|---|---|
| Picker search label (visually hidden) and placeholder | Search currency or country |
| Picker result count, announced | {n} currencies |
| Picker, no results | No currency matches "{query}" |
| Empty conversion line | Enter an amount to see the {code} equivalent |
| Quote loading | Getting today's rate |
| Quote expiring | Rate held for {s}s |
| Quote expired, with action | Rate expired · Refresh rate |
| Quote error, with action | Couldn't get a rate · Try again |
Over max | You can send up to {max} {code} |
| Rate update, announced | Rate 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 carriesaria-haspopup="dialog"andaria-expandedwhile 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-describedbyreferences, in order: the error if present, the currency'sname, the conversion line, the rate line, then the hint. - The conversion line renders its visible short form (
≈ 2,062,500.00 NGN) asaria-hiddenand 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
| Key | Action |
|---|---|
Tab | Amount field, then the currency tag |
Enter / Space on the currency tag | Open the currency picker |
| Digits and the locale decimal mark | Accepted (. is always accepted as an alias); other characters rejected without clearing |
Ctrl/Cmd+V | Normalised 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
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
| Part | Token |
|---|---|
| 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
| Variant | Dimension | Use |
|---|---|---|
| Default | layout | Mobile transaction history |
| Dense | layout | Dashboard "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
| State | Rendering |
|---|---|
| Default | --ds-bg |
| Hover | --ds-surface |
| Focus-visible | 3px 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
| Key | Action |
|---|---|
Tab | One stop for the whole row |
Enter | Open 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
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
| Variant | Dimension | Use |
|---|---|---|
| Horizontal | layout | Top of the transfer flow |
| Vertical | layout | Transaction 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
| State | Dot | Line | Token |
|---|---|---|---|
| Completed | Green fill, check icon | Green | --ds-timeline-completed-dot |
| Active | --ds-primary fill, pulsing | Default gray | --ds-timeline-active-dot, --ds-timeline-pulse-color |
| Upcoming | --ds-gray-300 outline | Default 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
| Key | Action |
|---|---|
Tab | One stop for the stepper |
← → | Between reachable steps |
Enter | Return 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
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
| Variant | Dimension | Frame |
|---|---|---|
document | type | 856:540 rectangle with L-shaped corner marks |
selfie | type | 160px 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
| State | Rendering |
|---|---|
| Framing | Viewfinder, frame marks, hint text |
| Aligned | Frame marks turn --ds-camera-frame-active |
| Captured | Still image, "Use photo" and "Retake", neither pre-selected |
| Unavailable | Manual 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
| Key | Action |
|---|---|
Tab | Shutter, then manual upload |
Enter / Space | Capture |
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
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
| State | Rendering |
|---|---|
| Scanning | Scan line loops; corner marks neutral |
| Success | Corner marks green, check icon replaces the line, announced |
| Error | Message plus retry; manual entry stays available |
| Reduced motion | Scan 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-motionthe 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
| Key | Action |
|---|---|
Tab | Manual entry control |
Enter | Open manual entry |
Tokens
--ds-qr-bg · --ds-qr-overlay · --ds-qr-frame · --ds-qr-scan-line · --ds-qr-hint · --ds-qr-manual-link
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 headerReceiptViewSection— grouped rows with optional titleReceiptViewRow— label + value pair usingdl/dt/ddsemanticsReceiptViewActions— footer with action buttons
Structure:
- Header: status icon + title + subtitle (date)
- Tear line: CSS
radial-gradientscalloped 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:
| Field | Why |
|---|---|
| Reference number | The only thing support can search on |
| Status and timestamp | What state it was in, and when |
| Sent amount + currency | What left |
| Received amount + currency | What arrived |
| Exchange rate applied | The rate at execution, not the current rate |
| Fees, itemized | Transfer fee and FX margin, separately |
| Sender and recipient | Names as they appear on the accounts |
| Payment method | Which 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
| Variant | Dimension | Use |
|---|---|---|
screen | type | In-app, system font, actions present |
export | type | PDF 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
| State | Rendering |
|---|---|
| Completed | Green status header with a check |
| Pending / Sent | Status header in the matching feedback set |
| Failed | Error header plus the reason, and the timeline stops where it failed |
| Export | Documentation 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-numsand are announced with their currency. - The status icon is
aria-hidden; status is text in the header.
Keyboard
| Key | Action |
|---|---|
Tab | Download, then share |
Enter | Activate |
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
From
To
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
| Variant | Dimension | Use |
|---|---|---|
| Grid | layout | Recipient selection on the send start screen |
| Row | layout | Contact 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
| State | Visual | Token |
|---|---|---|
| Default | --ds-contactcard-bg, --ds-contactcard-border | — |
| Hover | Background and shadow raise | --ds-contactcard-hover-bg, --ds-contactcard-hover-shadow |
| Focus-visible | Focus 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-pressedor by radio semantics when the grid is a single-choice group.
Keyboard
| Key | Action |
|---|---|
Tab | One stop for the card |
Enter / Space | Select and advance |
Tokens
--ds-contactcard-bg · --ds-contactcard-border · --ds-contactcard-hover-bg · --ds-contactcard-hover-shadow
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-labelper variant - QR scanner scan-line animation respects
prefers-reduced-motion: reduce - Receipt view uses semantic
articleroot,dl/dt/ddfor 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-numsso columns align and an outlier is visible. - A user's entered amount is never cleared by a validation error.
Do and don't
| Do | Don't |
|---|---|
| Mark a transfer "Sent" until the recipient has it | Show 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 scanner | Ship scan-only recipient entry |
| Keep the applied rate on the receipt forever | Re-render a receipt with today's rate |
Use inputmode="decimal" for amounts | Use type="number" with spinner arrows |
| Let the whole transaction row be one control | Add 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.