Feedback
Components that tell users what happened, what's loading, or what went wrong.
Feedback is the part of the system that carries the most weight in a transfer app, because the user's mental model of where their money is comes entirely from what these components say. A vague success message, a spinner with no timeout, or an error that does not say what to do next each cost more trust than any visual detail in the product.
Components
| Component | Purpose |
|---|---|
| Alert | Display contextual messages (info, warning, success, destructive) |
| Note | Inline callout for tips, guidance, and cautionary information |
| Error message | Show a specific validation error for a form field |
| Error summary | List all errors on the page with links to each field |
| Details | Expandable content block for supplementary information |
| Dialog | Modal overlay for confirmations and focused tasks |
| Alert dialog | Confirmation dialog requiring explicit user decision |
| Tooltip | Contextual hint on hover or focus |
| Toast | Transient notification that auto-dismisses |
| Progress | Visual indicator of task completion percentage |
| Skeleton | Placeholder shimmer during content loading |
| Inline loading | Small spinner for in-place loading indicators |
Choosing a feedback component
| The message is… | Use |
|---|---|
| Tied to one field | Error message |
| Blocking submission, multiple fields | Error summary |
| About the whole page or section, persistent | Alert |
| Supplementary guidance inside content | Note |
| Confirmation of an action just taken, transient | Toast |
| A decision the user must make before continuing | Dialog / Alert dialog |
| Hidden detail the user may want | Details |
| A hint attached to a control | Tooltip |
Two rules settle most arguments here. Persistence follows severity: anything the user must act on does not auto-dismiss. Proximity follows scope: a field error sits under its field, a page error sits at the top of the page.
Alert
Display contextual messages using color, icon, and text. The component is Alert (with AlertTitle, AlertDescription, and AlertAction sub-components).
| Variant | Usage |
|---|---|
default | Neutral contextual information |
info | Informational guidance |
warning | Caution before an action |
success | Confirmation of a completed action |
destructive | Something went wrong, action needed |
- Never use color alone to communicate status
- Include a descriptive icon and text label
- Place near the relevant content or at the top of the page
Anatomy
┌──────────────────────────────────────────┐
│ ▌ [icon] Title │ --ds-{variant}-text
│ ▌ Description text │ --ds-fg
│ ▌ [Action] │
└──────────────────────────────────────────┘
│ bg: --ds-{variant}-bg
└── border: --ds-{variant}-border
| Part | Required | Token |
|---|---|---|
| Container | Yes | --ds-{variant}-bg, --ds-{variant}-border |
| Icon | Yes | --ds-{variant}-icon, --ds-size-icon |
| Title | No | --ds-text-base, weight 600 |
| Description | Yes | --ds-text-sm |
| Action | No | Link-variant button |
Variants
| Variant | Dimension | Role | Use |
|---|---|---|---|
default | type | status | Neutral context |
info | type | status | Guidance |
success | type | status | A state that persists, not a momentary confirmation |
warning | type | alert | Caution before an action |
destructive | type | alert | Something went wrong and needs action |
API
type AlertVariant = "default" | "info" | "warning" | "success" | "destructive";
interface AlertProps {
variant?: AlertVariant; // default: "default"
title?: string;
action?: React.ReactNode; // a link-variant Button
onDismiss?: () => void; // omit for anything the user must act on
children: React.ReactNode; // the description — required
}
variant selects the ARIA role: warning and destructive render role="alert", the rest role="status". An alert is persistent; a message that disappears on a timer is a Toast.
States
An Alert has no interaction states — it is not a control. What it has is lifecycle:
| State | Rendering |
|---|---|
| Present | Persistent until the condition clears |
| Dismissible | Close control shown only when onDismiss is given, which is never for a blocking error |
| With action | Link-variant Button in the action slot; the Alert does not auto-dismiss |
Behavior
- Alerts are persistent. They are dismissible only when the information is genuinely optional; a blocking error has no close button.
- An alert never appears and disappears on a timer. That is a toast.
- Success alerts are used for states that persist ("Your account is verified"), not for momentary confirmations ("Saved") — those are toasts.
Content
Every alert answers what happened and what to do next. An alert that only states a condition leaves the user holding a problem.
| Do | Don't |
|---|---|
| "Rate expired — refresh to see the current rate" | "Rate expired" |
| "Verification failed — the ID photo was too blurry. Upload a clearer photo." | "Verification failed" |
| "This transfer is on hold while we check it. We'll email you within 2 hours." | "Transfer on hold" |
Accessibility
role="alert"for destructive and warning variants — these interrupt.role="status"for info and success — these wait for a pause.- The icon is
aria-hidden; the variant's meaning is in the text. - An alert that appears in response to an action must be announced, which means its container was already in the DOM. See Utilities.
Keyboard
| Key | Action |
|---|---|
Tab | Reaches the action and the dismiss control, if present |
| The body text itself is not focusable. |
Tokens
--ds-info-bg / -border / -text / -icon · --ds-success-* · --ds-warning-* · --ds-error-* · --ds-radius-lg
Note
Contextual callout for tips, guidance, and cautionary information within content.
- Left border accent (3px) with variant-specific color
- Subtle background tint for visual grouping
- Supports optional title for structured callouts
- Lighter weight than Notice/Alert — used inline within content, not as page-level feedback
| Variant | Border | Icon | Usage |
|---|---|---|---|
| info | Info blue | Info | Neutral contextual notes |
| tip | Accent blue | Lightbulb | Helpful suggestions and recommendations |
| caution | Warning yellow | TriangleAlert | Important caveats or time-sensitive info |
| error | Error red | CircleX | Failed operations or critical errors |
- Title is optional — description-only Notes are valid for brief callouts
- Icon can be overridden or hidden with
icon={false} - Rendered as
<aside role="note">for semantic clarity
Note or Alert
A Note is authored content — it is part of the page and is there on every load. An Alert is system feedback — it appears because of state. A Note never announces itself to a screen reader, because nothing changed; an Alert does.
The tip variant is the one that must not drift. A tip is optional and helpful. A caveat the user needs in order to avoid losing money is a caution or an Alert, not a tip.
Anatomy
A 3px border inline-start in the variant colour, a tinted background, an optional icon, an optional title, and the body.
Variants
| Variant | Dimension | Border | Icon | Use |
|---|---|---|---|---|
info | type | --ds-info-border | Info | Neutral contextual note |
tip | type | --ds-accent-400 | Lightbulb | Optional, helpful suggestion |
caution | type | --ds-warning-border | TriangleAlert | A caveat the reader needs |
error | type | --ds-error-border | CircleX | A failed operation described in prose |
tip is the one that drifts. A caveat that prevents losing money is caution or an Alert, never a tip.
API
interface NoteProps {
variant?: "info" | "tip" | "caution" | "error"; // default: "info"
title?: string;
icon?: React.ReactNode | false; // false hides it
children: React.ReactNode;
}
A Note is authored content and never announces itself — nothing changed. An Alert is system feedback and does.
States
Authored content, so it has no states. It renders identically on every load and never announces — nothing changed.
Behavior
Static. It does not dismiss, collapse, or announce.
Content
One idea per Note. A Note with three paragraphs is a section that should have a heading.
| Do | Don't |
|---|---|
| "Rates update every 30 seconds." | "Note:" as the title |
Put a fee caveat in caution | Put a fee caveat in tip |
Accessibility
Rendered as <aside role="note">. The icon is aria-hidden — the variant's meaning is carried by the words, which is why icon={false} is safe.
Keyboard
Not focusable and not interactive. It has no keyboard behaviour of its own. Inline links inside it follow Link behaviour.
Tokens
--ds-info-* · --ds-tip-* · --ds-warning-* · --ds-error-* · --ds-radius-md · --ds-space-4
Error message
Show a specific validation error for a form field.
- Appears below the input field
- Associated with the input via
aria-describedby - Uses red color + text description
- Visible immediately on validation failure
Anatomy
| Part | Token |
|---|---|
| Icon | --ds-error-icon, 16px |
| Text | --ds-error-text, --ds-text-sm |
| Gap from field | --ds-space-2 |
The field itself also changes: border to --ds-red-600, fill to --ds-error-bg, focus ring to --ds-focus-ring-error, and aria-invalid="true".
Variants
There is one. A field-level error is this component; a form-level error is Error summary; a page-level error is an Alert.
API
interface ErrorMessageProps {
id: string; // referenced by the field's aria-describedby
children: React.ReactNode;
}
Not a live region. The message is announced when the user returns to the field; announcing on every blur interrupts someone still filling in the form.
States
| State | Rendering |
|---|---|
| Absent | Nothing rendered; the field's aria-describedby points at the hint |
| Present | Below the control, hint stays; field border --ds-red-600, fill --ds-error-bg, aria-invalid="true" |
| Resolving | Clears as soon as the field becomes valid — live, because the user is actively correcting |
Behavior
Appears on blur or on submit, never on keystroke — except while the user is correcting an existing error, where it clears live so they can see they succeeded.
Content
An error message names the problem and the fix, in the user's terms:
| Do | Don't |
|---|---|
| "Enter an amount between 1.00 and 5,000.00 USD" | "Invalid amount" |
| "This account number is 10 digits — you entered 9" | "Format error" |
| "You don't have enough to send this. Your balance is 180.00 USD." | "Insufficient funds" |
| "Enter the recipient's full name as it appears on their bank account" | "Name required" |
Never expose an error code, a field name from the schema, or the word "invalid" on its own.
Accessibility
Referenced from the field's aria-describedby and paired with aria-invalid="true". Deliberately not a live region: announcing on every blur interrupts someone still working through the form.
Keyboard
Not focusable and not interactive. It has no keyboard behaviour of its own.
Tokens
--ds-error-bg · --ds-error-text · --ds-error-icon · --ds-red-600 · --ds-focus-ring-error · --ds-text-sm
Error summary
When a submission fails validation on multiple fields, an error summary appears at the top of the form listing every error as a link to its field.
| Requirement | Detail |
|---|---|
| Placement | Top of the form, above the first field |
| Role | role="alert" — this interrupts |
| Focus | Moves to the summary on failed submit |
| Links | Each item links to the field's id and moves focus there |
| Order | Matches the visual order of the fields |
Focus moving to the summary is what makes it work. A summary a screen reader user never lands on is a list nobody reads. Full pattern in Validation.
Anatomy
A bordered region with a heading stating the count, then an ordered list of links.
Variants
One.
API
interface ErrorSummaryProps {
errors: Array<{ fieldId: string; message: string }>; // in visual field order
heading?: (count: number) => string; // default: n => `There are ${n} problems with this form`
}
| Prop | Contract |
|---|---|
errors | Order must match the visual order of the fields, not the order validation produced them |
errors[].message | Identical to the field's inline message. Two wordings for one problem reads as two problems |
errors[].fieldId | Each item links to this id and moves focus there, not merely scrolls to it |
Server errors that belong to no single field — "This transfer exceeds your monthly limit" — appear at the top of the same list with fieldId omitted.
States
| State | Rendering |
|---|---|
| Hidden | No errors. The component is still mounted so its region pre-exists |
| Visible | One or more errors; focus moves here on failed submit |
| Resolving | Items disappear as their fields are corrected; the heading count updates |
There is no dismissed state. The summary leaves when the errors do.
Behavior
Appears on failed submit and receives focus. Items disappear as fields are corrected; the count updates. It leaves when the last error does and is never dismissed manually.
Content
The heading states the count. Each item repeats the field's inline message verbatim — two wordings for one problem reads as two problems.
Accessibility
role="alert"andtabIndex={-1}, receiving focus on failed submit.- Rendered as an ordered list, so the count is structural as well as in the heading.
- Each item is a real link to the field's
id.
Keyboard
| Key | Action |
|---|---|
Tab | Through the error links in field order |
Enter | Jump to the field and move focus there |
Tokens
--ds-error-bg · --ds-error-border · --ds-error-text · --ds-error-icon · --ds-radius-lg
Accessibility
- Associated with the input via
aria-describedby, not placed adjacent and hoped for. - The field carries
aria-invalid="true"while the error is present. - The message is not itself a live region — it is announced when the user returns to the field. Announcing on every blur interrupts a user who is still working.
- Color plus icon plus text. Red alone fails SC 1.4.1.
Details
Expandable content block for supplementary information.
- Used for fee breakdowns, additional context, and optional content
- Chevron indicator shows expand/collapse state
- Animates open and closed smoothly
- Built on the Accordion component with accessible keyboard support
What may not be hidden in Details
Fees, the exchange rate, and the total are visible before confirmation, not behind a disclosure. Details may hold the breakdown of a fee that is already shown as a total — it may never hold the existence of a cost. Hiding a charge behind a chevron is the pattern Design principles exists to prevent.
Anatomy
A summary row — chevron inline-start, label — and a panel below it.
Variants
One. A disclosure with variants is an Accordion.
API
interface DetailsProps {
summary: string;
defaultOpen?: boolean; // default: false
children: React.ReactNode;
}
May hold the breakdown of a cost already shown as a total. May never hold the existence of a cost.
States
| State | Rendering |
|---|---|
| Collapsed | Chevron inline-start, aria-expanded="false" |
| Expanded | aria-expanded="true"; height animates unless reduced motion is set |
| Disabled | Not offered. A disclosure the user cannot open should not be rendered |
Behavior
Opens and closes on its own trigger only. Does not close when a sibling opens — these are independent disclosures, not an accordion group.
Content
The summary names what is inside — "Fee breakdown" — so a user can decide whether to open it without opening it.
Accessibility
- The trigger is a
<button>witharia-expanded, andaria-controlspointing at the panel. - The chevron is
aria-hidden; state is carried byaria-expanded. - Height animation respects
prefers-reduced-motion— the content appears instantly rather than growing.
Keyboard
| Key | Action |
|---|---|
Tab | To the summary trigger |
Enter / Space | Toggle |
Tokens
--ds-disclosure-text · --ds-disclosure-icon · --ds-border · --ds-duration-normal
Dialog anatomy
Visual breakdown of the dialog component's internal structure.
| Region | Contains | Token |
|---|---|---|
| Overlay | Backdrop covering the page | --ds-overlay, --ds-z-overlay |
| Container | The dialog surface | --ds-white, --ds-radius-xl, --ds-shadow-lg, --ds-z-modal |
| Header | Title, optional close button | --ds-text-lg / 600 |
| Body | Description and content | --ds-text-base |
| Footer | Cancel and confirm actions | --ds-space-4 gap |
Padding is --ds-space-5 on all regions. On mobile below 600px the dialog becomes a bottom sheet with --ds-radius-2xl on the leading edge only.
Add new recipient
Enter the recipient details to save them for future transfers.
- 1Container
- 2Title
- 3Description
- 4Action buttons
Dialog
Modal overlay that blocks interaction with the page behind.
- Used for confirmations, destructive actions, and focused tasks
- Requires a clear title and description
- Always provide a cancel/close action
- Focus is trapped within the dialog while open
- Closes on Escape key or overlay click
Anatomy
Overlay, container, header with the title and optional close, body, footer with cancel and confirm.
API
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
role?: "dialog" | "alertdialog"; // default: "dialog"
title: string; // required
description?: string;
confirmLabel: string; // restates the action — "Delete recipient", never "Yes"
onConfirm: () => void;
cancelLabel?: string; // default: "Cancel"
destructive?: boolean; // default: false
mobileAsSheet?: boolean; // default: true below --ds-bp-mobile
children?: React.ReactNode; // form content for the focused-task variant
}
Variants
| Variant | Use | Initial focus |
|---|---|---|
| Basic | A focused task — editing a recipient, picking a currency | First field, or the dialog container |
| Form | Multi-field task inside the dialog | First field |
| Full-screen | Mobile, when the task fills the viewport | First field |
For a decision the user must make before continuing, use Alert dialog — it is a different role, not a variant.
States
| State | Behaviour |
|---|---|
| Closed | Not rendered; the trigger holds focus |
| Open | Focus moves in, content behind inert, body scroll locked |
| Submitting | Confirm shows inline loading and latches against re-submission |
| Closed | Focus returns to the trigger |
Behavior
Opening locks body scroll and marks the page behind inert. Escape and overlay click both close. Closing returns focus to the trigger. Dialogs do not stack.
Content
Title is a question when a decision follows ("Delete this recipient?"), a noun phrase when a task follows ("Edit recipient").
The description says what will happen — to the money first, then to the data.
Accessibility
role="dialog" with aria-modal="true", aria-labelledby on the title and aria-describedby on the description. Focus is trapped while open and returned on close.
Keyboard
| Key | Action |
|---|---|
Tab / Shift+Tab | Cycle within the dialog |
Esc | Close |
Tokens
--ds-overlay · --ds-z-overlay · --ds-z-modal · --ds-white · --ds-radius-xl · --ds-shadow-lg · --ds-duration-slow
Add new recipient
Enter the recipient details to save them for future transfers.
Alert dialog
A confirmation that interrupts to force a decision. Distinct from Dialog in role, in focus behaviour, and in when it is correct to use.
Anatomy
Same as Dialog. The difference is which control holds focus, not what is drawn.
API
AlertDialog takes DialogProps with role fixed to "alertdialog" and destructive defaulting to true. The distinction is not a styling prop — it changes which control receives initial focus.
type AlertDialogProps = Omit<DialogProps, "role"> & {
destructive?: boolean; // default: true — initial focus goes to cancel
};
Variants
| Dialog | Alert dialog | |
|---|---|---|
| Role | dialog | alertdialog |
| Purpose | A focused task | A decision that must be made |
| Escape / overlay click | Closes | Closes (treated as cancel) |
| Default focus | First field or the dialog | The safe action, never the destructive one |
| Used for | Editing a recipient, picking a currency | Deleting a contact, cancelling a transfer |
Focus defaults to the safe action in an alert dialog. A destructive confirm button that already has focus turns a reflexive Enter into a deleted recipient.
States
| State | Behaviour |
|---|---|
| Closed | Not rendered; the trigger holds focus |
| Open | Focus on the safe action, content behind inert, body scroll locked |
| Confirming | Confirm shows inline loading and latches; cancel stays enabled |
| Closed by Escape | Treated as cancel — never as confirm |
Behavior
Escape and overlay click are both treated as cancel, never as confirm. The confirm action latches on first press.
Content
The confirm label restates the action so it reads correctly out of context: "Delete recipient", not "Yes". The body states what will happen to the user's money or data, in that order.
Accessibility
role="alertdialog"witharia-labelledbyandaria-describedbyboth required — unlike Dialog, the description is not optional.- Initial focus is the safe action.
- Escape cancels.
Tokens
Same as Dialog: --ds-overlay · --ds-z-modal · --ds-radius-xl · --ds-shadow-lg
Keyboard
| Key | Action |
|---|---|
Tab / Shift+Tab | Cycle within the dialog |
Esc | Cancel and close |
Enter | Activate the focused control — which is cancel by default |
API
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
role?: "dialog" | "alertdialog"; // default: "dialog"
title: string; // required
description: string; // required
confirmLabel: string; // restates the action — "Delete recipient", never "Yes"
onConfirm: () => void;
cancelLabel?: string; // default: "Cancel"
destructive?: boolean; // default: false — when true, initial focus goes to cancel
mobileAsSheet?: boolean; // default: true below --ds-bp-mobile
}
role="alertdialog" focuses the safe action. A destructive confirm button that already holds focus turns a reflexive Enter into a deleted recipient.
Content
The confirm button restates the action rather than agreeing with the question. "Delete recipient", not "Yes" — the button must be correct when read on its own, because that is how a screen reader user encounters it.
| Title | Body | Confirm |
|---|---|---|
| "Delete this recipient?" | "Maria Santos will be removed from your contacts. Past transfers stay in your history." | "Delete recipient" |
| "Cancel this transfer?" | "The 250.00 USD will be returned to your balance within 1 hour." | "Cancel transfer" |
Accessibility
aria-modal="true"witharia-labelledbypointing at the title andaria-describedbyat the description.- Focus is trapped while open and returns to the trigger on close.
- Content behind is
inert, so a screen reader cannot browse into it. Esccloses. A dialog that cannot be escaped is a trap.- Body scroll is locked.
Tokens
--ds-overlay · --ds-z-overlay · --ds-z-modal · --ds-radius-xl · --ds-shadow-lg · --ds-duration-slow
Tooltip
Small hint that appears on hover or keyboard focus.
- Used for supplementary information (e.g., fee explanations)
- Should not contain essential information
- Positioned automatically to avoid viewport overflow
Interaction requirements
- Must show on both
:hoverand:focus(never hover-only) - Persists for minimum 1.5 seconds after trigger
- Dismisses on
Escapekey press - Content must be hoverable (user can mouse over the tooltip itself)
- Touch devices: show on long-press, dismiss on tap elsewhere
Tooltips are not available on touch
Most Senco users are on phones, where there is no hover. A tooltip is an enhancement for pointer users only, and anything inside one must also exist somewhere a touch user can reach — usually as helper text under the field or inside a Details block. A fee explanation that lives only in a tooltip is invisible to the majority of the audience.
Anatomy
A small dark surface with an arrow pointing at the trigger. Plain text only.
Variants
One. A tooltip needing structure or a link is a Popover.
API
interface TooltipProps {
content: string; // plain text — interactive content means this should be a Popover
children: React.ReactElement; // the trigger, which supplies its own accessible name
}
Attached via aria-describedby. A tooltip describes; it does not name. There is no hover on touch, so anything inside one must also exist somewhere a touch user can reach.
States
| State | Rendering |
|---|---|
| Hidden | Default |
| Shown | On hover and on keyboard focus; persists at least 1.5s |
| Dismissed | Esc, or pointer leaves both trigger and tooltip |
There is no touch state. On touch this component does not appear at all, which is why its content must exist elsewhere.
Behavior
Repositions to stay in the viewport. Never covers its own trigger. Only one tooltip is visible at a time.
Content
One short sentence, no full stop. Never essential information — there is no hover on touch.
Accessibility
- Associated with the trigger via
aria-describedby. A tooltip is a description, not a name. - Never put interactive content inside a tooltip — if it needs a link or a button, it is a popover.
- SC 1.4.13 requires it to be dismissible, hoverable, and persistent.
- An icon-only trigger still needs its own
aria-label; the tooltip does not supply the name.
Keyboard
| Key | Action |
|---|---|
Tab | Focusing the trigger shows the tooltip |
Esc | Dismiss without moving focus |
Tokens
--ds-heading · --ds-white · --ds-radius-md · --ds-shadow-md · --ds-z-popover
Toast
Transient notification that appears briefly and auto-dismisses.
- Used to confirm completed actions (e.g., "Transfer sent")
- Error toasts persist until manually dismissed
- Positioned at the bottom of the viewport
- Supports success, error, and neutral variants
Anatomy
An icon, a title, an optional description, an optional action, and a dismiss control.
Variants
| Variant | Dimension | Role |
|---|---|---|
success | type | role="status" |
neutral | type | role="status" |
error | type | role="alert", never auto-dismisses |
API
interface ToastProps {
variant?: "success" | "error" | "neutral"; // default: "neutral"
title: string;
description?: string;
action?: { label: string; onAction: () => void }; // forces duration to Infinity (SC 2.2.1)
duration?: number; // default: 5000; errors default to Infinity
}
A toast is the notification, never the record. A failed transfer produces a toast and a persistent state change in the transaction list.
States
| State | Rendering |
|---|---|
| Entering | --ds-duration-normal, --ds-ease-out |
| Shown | 5s for success and neutral; indefinite for errors and for any toast with an action |
| Stacked | Maximum 3; the oldest collapses |
| Exiting | --ds-duration-normal, --ds-ease-in |
Behavior
| Property | Value |
|---|---|
| Auto-dismiss (success, neutral) | ~5s |
| Auto-dismiss (error) | Never — manual dismiss only |
| Position | Bottom, above the tab bar and safe-area inset |
| Stacking | Maximum 3; oldest collapses |
| Z-index | --ds-z-toast (600) — above modal |
Toast sits above modal deliberately. A transfer failure has to be readable while a confirmation dialog is still open.
What a toast may not carry
A toast disappears, so nothing whose loss matters may live only there. A failed transfer produces a toast and a persistent state change in the transaction list. A toast is the notification, never the record.
Content
The title states what happened in the past tense — "Transfer sent". The description adds only what the title cannot carry.
Accessibility
- Success and neutral toasts use
role="status"(polite). Error toasts userole="alert"(assertive). - An auto-dismissing toast with an action inside it fails SC 2.2.1 — an action means the toast must persist.
- Dismiss control is a real button with an accessible name, 48px target.
- The toast container is mounted at all times; only its children change.
Keyboard
| Key | Action |
|---|---|
Tab | Reaches the action and the dismiss control |
Esc | Dismisses the focused toast |
Tokens
--ds-success-* · --ds-error-* · --ds-white · --ds-shadow-lg · --ds-z-toast · --ds-radius-lg
Progress
Visual bar showing task completion percentage.
- Used for uploads, transfer progress, and loading states
- Pair with a text label showing percentage or step
- Supports determinate (percentage) and indeterminate (loading) modes
- Color indicates status: primary for in-progress, green for complete
Determinate wherever possible
An indeterminate bar tells the user nothing except that the app has not given up. Where a real percentage exists — a document upload, a multi-step verification — it is shown. Where it does not, a step label ("Checking recipient details") beats a bare animation, because it says what is happening rather than that something is.
Anatomy
A track, a fill, and a text percentage or step label beside it. The text is required; the bar alone is unreadable to a colourblind or low-vision user.
Variants
| Variant | Dimension | Use |
|---|---|---|
| Linear determinate | type | Upload, verification — a real percentage exists |
| Linear indeterminate | type | No percentage available; paired with a step label |
| Circular | type | Inline, compact contexts |
API
interface ProgressProps {
value?: number; // 0–100; omit for indeterminate
label: string; // "Uploading passport.jpg", not "Loading"
showPercentage?: boolean; // default: true when value is given
status?: "active" | "complete" | "error"; // default: derived
}
Announcements are throttled to every 10% or 1s, whichever is less frequent.
States
| State | Rendering |
|---|---|
| Indeterminate | value omitted; no aria-valuenow; paired with a step label, never bare |
| Determinate | Fill at value%, percentage rendered as text beside the bar |
| Complete | Fill --ds-green-600, check icon |
| Error | Fill --ds-red-600, retry action beside it |
Behavior
Never moves backwards. A recalculated total re-scales the fill rather than resetting it. Reaching 100% does not imply success — status does.
Content
Pair the bar with what is progressing, not with "Please wait".
Accessibility
role="progressbar"witharia-valuenow,aria-valuemin, andaria-valuemaxfor determinate mode; omitaria-valuenowfor indeterminate.- The percentage is visible text as well as an ARIA value. Colorblind and low-vision users cannot read a bar's fill.
- Progress updates are announced politely, and throttled — announcing every percentage point is unusable.
Keyboard
Not focusable and not interactive. It has no keyboard behaviour of its own. A retry action beside it is a Button.
Tokens
--ds-primary · --ds-green-600 · --ds-red-600 · --ds-surface · --ds-radius-full
Skeleton
Placeholder shimmer that mimics the shape of loading content.
- Used while data is being fetched
- Matches the layout of the final content to prevent layout shift
- Replaces spinners for structured content areas
- Includes circular (avatar), rectangular (text), and card shapes
Why skeletons over spinners
A skeleton shows the shape of what is coming, which makes the wait feel shorter and prevents the layout shift that a spinner-to-content swap causes. It also matches the "never a blank screen" rule in Design principles.
A skeleton must match the final layout closely. A skeleton that is the wrong height causes the exact content shift it exists to prevent, and a user reaching for a button that moves is the worst outcome in a flow that sends money.
Anatomy
A filled shape in --ds-surface at the size of the content it stands in for.
Variants
| Variant | Dimension | Matches |
|---|---|---|
text | layout | One or more lines at body height |
circle | layout | Avatar or flag |
rect | layout | Image or chart area |
card | layout | A whole card, including its padding |
API
interface SkeletonProps {
shape?: "text" | "circle" | "rect" | "card"; // default: "text"
lines?: number; // shape="text" only
width?: string;
height?: string;
}
The skeleton must match the final layout's height. A skeleton of the wrong height causes the exact content shift it exists to prevent.
States
| State | Rendering |
|---|---|
| Shimmering | Default; aria-busy="true" on the region |
| Static | Under prefers-reduced-motion the placeholder remains and the shimmer stops |
There is no error state. A failed load replaces the skeleton with an error state, it does not leave a skeleton behind.
Behavior
Replaced by content, by an empty state, or by an error state — never left behind. The region carries aria-busy until one of the three arrives.
Content
None. A skeleton that contains text is not a skeleton.
Accessibility
- The skeleton region is
aria-busy="true"and its contentaria-hidden. - A polite live region announces "Loading transactions" once, not on each shimmer cycle.
- The shimmer animation stops under
prefers-reduced-motion; the placeholder stays.
Keyboard
Not focusable and not interactive. It has no keyboard behaviour of its own.
Tokens
--ds-surface · --ds-radius-md · --ds-duration-slower
Inline loading
Small spinner for in-place loading indicators within content or controls.
- Used when: fetching exchange rates, verifying recipient details, checking balances
- Appears inside buttons, beside text labels, or within table cells
- States: loading (spinner), success (checkmark transition), error (retry)
- Spinner size matches the context (14px inside buttons, 16px beside text)
- Accessible: uses
aria-live="polite"to announce state changes - Does not block user interaction with other page elements
Every spinner needs a timeout
An inline spinner with no failure path spins forever on a dropped connection, and Senco's users drop connections routinely. Every loading state has a timeout that resolves to the error state with a retry action. A permanent spinner is the single most common way this product could lie to a user about what is happening to their money.
Anatomy
A spinner sized to its context, then a label. In a button the spinner replaces the leading icon and the label stays.
Variants
| Variant | Dimension | Size |
|---|---|---|
| In button | layout | 14px, replaces the leading icon |
| Beside text | layout | 16px, inline with the label |
| In cell | layout | 16px, replaces the value |
API
interface InlineLoadingProps {
state: "loading" | "success" | "error";
label: string; // announced politely
size?: 14 | 16; // default: 16; 14 inside a button
onRetry?: () => void; // required when state can reach "error"
timeoutMs?: number; // default: 10000 — see Tokens > Timing and threshold values
}
Every spinner resolves. A loading state with no timeout spins forever on a dropped connection, which is the normal case for this product's users.
States
| State | Rendering |
|---|---|
| Loading | Spinner plus label; announced politely once |
| Success | Check icon; the spinner does not linger |
| Error | Alert icon plus a retry action |
| Timed out | Becomes the error state at timeoutMs. There is no fourth outcome |
Behavior
Announces once on entering the loading state, not on a loop. Resolves to success or error within timeoutMs; it has no third outcome. Does not block interaction elsewhere on the page.
Content
The label says what is happening — "Checking recipient details" — not "Loading".
Accessibility
- The spinner is
aria-hidden; the state is announced through a polite live region. - The control it sits inside is
aria-disabled, notdisabled, so it stays focusable and can be described. - Under reduced motion the spinner stops rotating and stays visible as a static glyph. The label carries the state, which is why the label is required.
Keyboard
The indicator is not focusable. The retry action is a Button.
Tokens
--ds-primary · --ds-green-600 · --ds-red-600 · --ds-size-icon-sm
Design and accessibility
- Alerts use color, icon, and text to communicate status (not color alone)
- Error summaries appear at the top of the page and link to each error
- Error messages are associated with their input via
aria-describedby - Details component uses native disclosure pattern
- Dynamic feedback uses
aria-liveregions for screen reader announcement - Focus moves to error summary when form submission fails
- Dialog traps focus and returns it on close
Rules that apply to every component here
- Severity decides persistence. Anything the user must act on does not auto-dismiss.
- Scope decides placement. Field errors under the field, page errors at the top.
- Every message says what to do next. Stating the problem alone is half a message.
- Nothing important exists only in a transient surface. Toasts and tooltips are never the only carrier.
- Every loading state has a failure state. No spinner without a timeout.
- Color, icon, and text together. Every time.
Do and don't
| Do | Don't |
|---|---|
| "Rate expired — refresh to see the current rate" | "Rate expired" |
| Let an error toast persist until dismissed | Auto-dismiss a failure after 5 seconds |
| Focus the error summary on failed submit | Render a summary nobody lands on |
| Focus the safe action in an alert dialog | Focus "Delete" by default |
| Show the fee, hide only its breakdown | Hide the existence of a fee behind Details |
| Give every spinner a timeout and a retry | Ship a spinner with no failure path |
Known gaps
- "Alert dialog" and "Error summary" appear in the component table but are documented as subsections rather than having their own specs.
- The live-region preview key in the documentation site is
Noticewhile this page's heading isAlert, so that section renders without its visual. - No skeleton shapes are specified per component.
- Toast stacking behavior is stated but not implemented.