Skip to main content
ENKO

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

ComponentPurpose
AlertDisplay contextual messages (info, warning, success, destructive)
NoteInline callout for tips, guidance, and cautionary information
Error messageShow a specific validation error for a form field
Error summaryList all errors on the page with links to each field
DetailsExpandable content block for supplementary information
DialogModal overlay for confirmations and focused tasks
Alert dialogConfirmation dialog requiring explicit user decision
TooltipContextual hint on hover or focus
ToastTransient notification that auto-dismisses
ProgressVisual indicator of task completion percentage
SkeletonPlaceholder shimmer during content loading
Inline loadingSmall spinner for in-place loading indicators

Choosing a feedback component

The message is…Use
Tied to one fieldError message
Blocking submission, multiple fieldsError summary
About the whole page or section, persistentAlert
Supplementary guidance inside contentNote
Confirmation of an action just taken, transientToast
A decision the user must make before continuingDialog / Alert dialog
Hidden detail the user may wantDetails
A hint attached to a controlTooltip

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).

VariantUsage
defaultNeutral contextual information
infoInformational guidance
warningCaution before an action
successConfirmation of a completed action
destructiveSomething 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
PartRequiredToken
ContainerYes--ds-{variant}-bg, --ds-{variant}-border
IconYes--ds-{variant}-icon, --ds-size-icon
TitleNo--ds-text-base, weight 600
DescriptionYes--ds-text-sm
ActionNoLink-variant button

Variants

VariantDimensionRoleUse
defaulttypestatusNeutral context
infotypestatusGuidance
successtypestatusA state that persists, not a momentary confirmation
warningtypealertCaution before an action
destructivetypealertSomething 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:

StateRendering
PresentPersistent until the condition clears
DismissibleClose control shown only when onDismiss is given, which is never for a blocking error
With actionLink-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.

DoDon'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

KeyAction
TabReaches 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
VariantBorderIconUsage
infoInfo blueInfoNeutral contextual notes
tipAccent blueLightbulbHelpful suggestions and recommendations
cautionWarning yellowTriangleAlertImportant caveats or time-sensitive info
errorError redCircleXFailed 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

VariantDimensionBorderIconUse
infotype--ds-info-borderInfoNeutral contextual note
tiptype--ds-accent-400LightbulbOptional, helpful suggestion
cautiontype--ds-warning-borderTriangleAlertA caveat the reader needs
errortype--ds-error-borderCircleXA 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.

DoDon't
"Rates update every 30 seconds.""Note:" as the title
Put a fee caveat in cautionPut 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

Note

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

PartToken
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

StateRendering
AbsentNothing rendered; the field's aria-describedby points at the hint
PresentBelow the control, hint stays; field border --ds-red-600, fill --ds-error-bg, aria-invalid="true"
ResolvingClears 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:

DoDon'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 message
Enter a valid email address

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.

RequirementDetail
PlacementTop of the form, above the first field
Rolerole="alert" — this interrupts
FocusMoves to the summary on failed submit
LinksEach item links to the field's id and moves focus there
OrderMatches 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`
}
PropContract
errorsOrder must match the visual order of the fields, not the order validation produced them
errors[].messageIdentical to the field's inline message. Two wordings for one problem reads as two problems
errors[].fieldIdEach 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

StateRendering
HiddenNo errors. The component is still mounted so its region pre-exists
VisibleOne or more errors; focus moves here on failed submit
ResolvingItems 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" and tabIndex={-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

KeyAction
TabThrough the error links in field order
EnterJump 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

StateRendering
CollapsedChevron inline-start, aria-expanded="false"
Expandedaria-expanded="true"; height animates unless reduced motion is set
DisabledNot 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> with aria-expanded, and aria-controls pointing at the panel.
  • The chevron is aria-hidden; state is carried by aria-expanded.
  • Height animation respects prefers-reduced-motion — the content appears instantly rather than growing.

Keyboard

KeyAction
TabTo the summary trigger
Enter / SpaceToggle

Tokens

--ds-disclosure-text · --ds-disclosure-icon · --ds-border · --ds-duration-normal

Accordion

Dialog anatomy

Visual breakdown of the dialog component's internal structure.

RegionContainsToken
OverlayBackdrop covering the page--ds-overlay, --ds-z-overlay
ContainerThe dialog surface--ds-white, --ds-radius-xl, --ds-shadow-lg, --ds-z-modal
HeaderTitle, optional close button--ds-text-lg / 600
BodyDescription and content--ds-text-base
FooterCancel 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.

Dialog anatomy
1

Add new recipient

2

Enter the recipient details to save them for future transfers.

3
4
  1. 1Container
  2. 2Title
  3. 3Description
  4. 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

VariantUseInitial focus
BasicA focused task — editing a recipient, picking a currencyFirst field, or the dialog container
FormMulti-field task inside the dialogFirst field
Full-screenMobile, when the task fills the viewportFirst field

For a decision the user must make before continuing, use Alert dialog — it is a different role, not a variant.

States

StateBehaviour
ClosedNot rendered; the trigger holds focus
OpenFocus moves in, content behind inert, body scroll locked
SubmittingConfirm shows inline loading and latches against re-submission
ClosedFocus 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

KeyAction
Tab / Shift+TabCycle within the dialog
EscClose

Tokens

--ds-overlay · --ds-z-overlay · --ds-z-modal · --ds-white · --ds-radius-xl · --ds-shadow-lg · --ds-duration-slow

Dialog

Add new recipient

Enter the recipient details to save them for future transfers.

Recipient details are encrypted end-to-end.

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

DialogAlert dialog
Roledialogalertdialog
PurposeA focused taskA decision that must be made
Escape / overlay clickClosesCloses (treated as cancel)
Default focusFirst field or the dialogThe safe action, never the destructive one
Used forEditing a recipient, picking a currencyDeleting 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

StateBehaviour
ClosedNot rendered; the trigger holds focus
OpenFocus on the safe action, content behind inert, body scroll locked
ConfirmingConfirm shows inline loading and latches; cancel stays enabled
Closed by EscapeTreated 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" with aria-labelledby and aria-describedby both 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

KeyAction
Tab / Shift+TabCycle within the dialog
EscCancel and close
EnterActivate 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.

TitleBodyConfirm
"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" with aria-labelledby pointing at the title and aria-describedby at 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.
  • Esc closes. 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 :hover and :focus (never hover-only)
  • Persists for minimum 1.5 seconds after trigger
  • Dismisses on Escape key 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

StateRendering
HiddenDefault
ShownOn hover and on keyboard focus; persists at least 1.5s
DismissedEsc, 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

KeyAction
TabFocusing the trigger shows the tooltip
EscDismiss without moving focus

Tokens

--ds-heading · --ds-white · --ds-radius-md · --ds-shadow-md · --ds-z-popover

Tooltip

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

VariantDimensionRole
successtyperole="status"
neutraltyperole="status"
errortyperole="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

StateRendering
Entering--ds-duration-normal, --ds-ease-out
Shown5s for success and neutral; indefinite for errors and for any toast with an action
StackedMaximum 3; the oldest collapses
Exiting--ds-duration-normal, --ds-ease-in

Behavior

PropertyValue
Auto-dismiss (success, neutral)~5s
Auto-dismiss (error)Never — manual dismiss only
PositionBottom, above the tab bar and safe-area inset
StackingMaximum 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 use role="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

KeyAction
TabReaches the action and the dismiss control
EscDismisses the focused toast

Tokens

--ds-success-* · --ds-error-* · --ds-white · --ds-shadow-lg · --ds-z-toast · --ds-radius-lg

Toast

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

VariantDimensionUse
Linear determinatetypeUpload, verification — a real percentage exists
Linear indeterminatetypeNo percentage available; paired with a step label
CirculartypeInline, 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

StateRendering
Indeterminatevalue omitted; no aria-valuenow; paired with a step label, never bare
DeterminateFill at value%, percentage rendered as text beside the bar
CompleteFill --ds-green-600, check icon
ErrorFill --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" with aria-valuenow, aria-valuemin, and aria-valuemax for determinate mode; omit aria-valuenow for 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

Progress
Uploading ID document...35%
x
Transfer in progress70%
x
Complete100%
x

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

VariantDimensionMatches
textlayoutOne or more lines at body height
circlelayoutAvatar or flag
rectlayoutImage or chart area
cardlayoutA 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

StateRendering
ShimmeringDefault; aria-busy="true" on the region
StaticUnder 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 content aria-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

Skeleton

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

VariantDimensionSize
In buttonlayout14px, replaces the leading icon
Beside textlayout16px, inline with the label
In celllayout16px, 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

StateRendering
LoadingSpinner plus label; announced politely once
SuccessCheck icon; the spinner does not linger
ErrorAlert icon plus a retry action
Timed outBecomes 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, not disabled, 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

Inline loading
Inside button
Beside text
Verifying recipient...
Success state
Recipient verified

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-live regions 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

DoDon't
"Rate expired — refresh to see the current rate""Rate expired"
Let an error toast persist until dismissedAuto-dismiss a failure after 5 seconds
Focus the error summary on failed submitRender a summary nobody lands on
Focus the safe action in an alert dialogFocus "Delete" by default
Show the fee, hide only its breakdownHide the existence of a fee behind Details
Give every spinner a timeout and a retryShip 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 Notice while this page's heading is Alert, so that section renders without its visual.
  • No skeleton shapes are specified per component.
  • Toast stacking behavior is stated but not implemented.