This guide defines the design patterns, visual treatments, and implementation details for user interface states in the RemitWise platform. It is written for frontend contributors to ensure a consistent experience across form submissions, dashboard widgets, and data loading boundaries.
Every interactive UI component in RemitWise must explicitly handle six standard component states:
| State | Purpose | Visual Signature & Tokens |
|---|---|---|
| Default | Resting/idle interactive state | bg-black, border-white/10, text-white |
| Hover | User hovers mouse over interactive element | hover:border-white/20, hover:bg-brand.redHover |
| Focus | Keyboard navigation or active input focus | focus:ring-2, focus:ring-brand.red, focus:ring-offset-2 |
| Disabled | Action unavailable or request in-flight | disabled:opacity-50, disabled:cursor-not-allowed |
| Error | Form validation error or widget render failure | bg-status-error-soft, text-status-error-fg |
| Loading | Data fetching or background operation pending | Route-level Skeleton screens, inline Loader2 spinners |
The default state represents components in their idle, interactive form ready for user interaction.
All styling utilizes Tailwind CSS and respects our global design tokens configured in tailwind.config.js. Avoid hardcoding hex colors, border radii, or spacing values.
Key interactive tokens:
- Brand Accent:
bg-brand.red(#DC2626) - Container Background:
bg-black/bg-[#0A0A0A] - Borders:
border-white/10
import React from 'react';
export interface TextInputProps {
label: string;
name: string;
placeholder?: string;
defaultValue?: string;
}
export function TextInput({
label,
name,
placeholder,
defaultValue,
}: TextInputProps) {
return (
<div className="grid gap-1.5">
<label
htmlFor={name}
className="block text-sm font-medium text-gray-300"
>
{label}
</label>
<input
type="text"
id={name}
name={name}
defaultValue={defaultValue}
placeholder={placeholder}
className="w-full px-4 py-3 bg-[#0A0A0A] border border-white/10 rounded-lg text-white placeholder-gray-500 transition-colors duration-200"
/>
</div>
);
}The hover state provides visual feedback when a user moves their cursor over interactive elements such as buttons, inputs, cards, and links.
- Buttons: Shift background tint to hover variant (
hover:bg-brand.redHover). - Input Fields: Increase border opacity/brightness (
hover:border-white/20). - Interactive Cards & Items: Subtle elevation or background highlight transition (
hover:bg-white/[0.04]).
import React from 'react';
export function ActionButton({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="w-full bg-brand.red hover:bg-brand.redHover text-white px-6 py-3 rounded-lg font-semibold transition-colors duration-200"
>
{children}
</button>
);
}The focus state guarantees accessibility (WCAG compliance) for keyboard users and screen readers during navigation.
- Focus Ring: Always apply
focus:outline-none focus:ring-2 focus:ring-brand.red focus:ring-offset-2 focus:ring-offset-black. - Border Integration: Clear border contrast when focused (
focus:border-transparent). - Accessibility: Never remove outline without providing a visible focus ring replacement.
- For the full focus management reference — focus-visible styles, hooks, traps, and testing patterns — see Accessible Focus Baseline.
import React from 'react';
export function AccessibleInput({ label, id }: { label: string; id: string }) {
return (
<div className="space-y-1">
<label htmlFor={id} className="text-sm font-medium text-gray-300">
{label}
</label>
<input
id={id}
type="text"
className="w-full px-4 py-3 bg-[#0A0A0A] border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-brand.red focus:ring-offset-2 focus:ring-offset-black focus:border-transparent transition-all"
/>
</div>
);
}Interactive controls are placed in a disabled state for two reasons:
- In-Flight Requests: Form inputs and submit buttons must be disabled during active submissions to prevent duplicate form submissions or double-spends.
- Feature Boundaries: Features waiting for integration or prerequisite user input disable fields to guide the user flow.
- Apply
disabled:opacity-50anddisabled:cursor-not-allowed. - Text color is muted (
text-gray-500ortext-white/30). - Borders are softened (
border-white/5orborder-gray-200/10).
import React from 'react';
export function DisabledField({ label, value }: { label: string; value: string }) {
return (
<div className="grid gap-1">
<label className="block text-sm font-medium text-gray-400">{label}</label>
<input
type="text"
value={value}
disabled
readOnly
className="w-full px-4 py-3 border border-white/5 bg-white/[0.02] rounded-lg text-white/50 cursor-not-allowed opacity-50 focus:outline-none"
/>
</div>
);
}RemitWise handles error states at two levels: form validation / API responses and component / widget rendering failures.
Form submissions utilize the useFormAction hook. The hook handles error resolution priority and returns errors within the state object.
import { useFormAction } from '@/lib/hooks/useFormAction';
export function SendForm() {
const [state, formAction, isPending] = useFormAction('/api/send');
return (
<form action={formAction} className="space-y-4">
{/* Standard error banner using semantic red color tokens */}
{state?.error && (
<div className="p-3 bg-status-error-soft border border-status-error-border rounded-lg text-status-error-fg text-sm">
{state.error}
</div>
)}
{/* Inputs disabled during submission */}
<input
type="number"
name="amount"
disabled={isPending}
className="w-full border border-white/10 bg-black text-white p-3 rounded-lg focus:ring-2 focus:ring-brand.red disabled:opacity-50"
/>
<button
type="submit"
disabled={isPending}
className="w-full bg-brand.red hover:bg-brand.redHover text-white px-6 py-3 rounded-lg font-semibold transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending ? 'Sending...' : 'Send'}
</button>
</form>
);
}If an individual widget fails during rendering, a reusable WidgetErrorBoundary catches the failure, logs the incident via the server logging service, and renders WidgetErrorState without crashing the rest of the application.
- Boundary Component:
components/ui/WidgetErrorBoundary.tsx - Fallback State UI:
components/ui/WidgetErrorState.tsx
import WidgetErrorBoundary from '@/components/ui/WidgetErrorBoundary';
import MyWidgetComponent from './MyWidgetComponent';
export function DashboardLayout() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Wrap widgets individually to isolate errors */}
<WidgetErrorBoundary widgetName="MyWidgetComponent">
<MyWidgetComponent />
</WidgetErrorBoundary>
</div>
);
}To prevent layout shifts and provide a premium user experience, RemitWise uses route-level skeleton screens instead of generic spinners for major layout sections. Inline loading spinners are reserved for form submit action buttons.
For guidance on when to use loading.tsx, React.Suspense, and when to keep explicit manual fetch state, see docs/SUSPENSE.md.
Located in components/ui/Skeleton.tsx, the Skeleton components animate using a shimmer effect.
We support three primary layout skeletons:
SkeletonCard: Standard placeholder block. Variants include"default","stat", and"chart".SkeletonList: List layout wrapper. Variants include"table"and"cards".DashboardLoadingSkeleton: High-level dashboard shell.
import { SkeletonCard } from "@/components/ui/Skeleton";
export function WidgetLoading() {
return (
<div className="space-y-4">
<h3 className="text-white font-medium">Analytics Preview</h3>
{/* Renders a stat card placeholder with animated shimmer */}
<SkeletonCard variant="stat" />
</div>
);
}Wrap one or more Skeleton shapes in <SkeletonGroup> to give screen-reader
users a polite announcement while the placeholder is on screen.
import { Skeleton, SkeletonGroup } from "@/components/ui/Skeleton";
export function TransactionListLoading() {
return (
<SkeletonGroup label="Loading transaction history" className="space-y-3">
<Skeleton className="h-12 w-full rounded-xl" />
<Skeleton className="h-12 w-full rounded-xl" />
<Skeleton className="h-12 w-full rounded-xl" />
</SkeletonGroup>
);
}SkeletonGroup renders:
role="status"/aria-busy="true"— a polite live region.aria-labelset to thelabelprop (defaults to"Loading").- A visually-hidden
<span className="sr-only">containing the label text so the announcement is present in the accessibility tree even in browsers that derive the accessible name from content rather thanaria-label. - All
childrenrendered normally — individual<Skeleton>shapes remainaria-hidden="true"(decorative only). data-loading-state="skeleton"for CSS/test selector hooks.
Do not nest SkeletonGroup inside another SkeletonGroup — nesting
creates double announcements. One group per loading surface is sufficient.
Skeletons and loader components expose custom CSS properties and semantic class/attribute hooks for layout styling and downstream theming:
The rw-skeleton / rw-skeleton--shimmer classes (used by every <Skeleton>)
draw from three theme tokens:
| Variable | Purpose |
|---|---|
--skeleton-static |
Flat fill used by the static variant and by the reduced-motion fallback |
--skeleton-base |
Base colour of the shimmer gradient sweep |
--skeleton-highlight |
Peak highlight colour that travels across the shimmer |
All three tokens have both light-mode and dark-mode defaults in
app/globals.css (inside :root and @media (prefers-color-scheme: dark) > :root
respectively). Operators and downstream consumers can override any or all of
them on :root or on a scoped selector without touching component source.
The legacy .loading-skeleton class (used on route-level wrappers) exposes a
separate, backwards-compatible set:
| Variable | Purpose |
|---|---|
--skeleton-bg-start |
Start colour of the legacy gradient |
--skeleton-bg-via |
Mid-point highlight of the legacy gradient |
--skeleton-bg-end |
End colour of the legacy gradient |
- Individual placeholder shape:
.rw-skeleton/.rw-skeleton--shimmer - Live-region group:
[data-loading-state="skeleton"] - Dashboard shell:
.loading-skeleton-dashboard/data-loading-state="dashboard" - Bills shell:
.loading-skeleton-bills/data-loading-state="bills" - Insights shell:
.loading-skeleton-insights/data-loading-state="insights" - Skeleton Card:
.loading-skeleton-card/data-loading-state="card" - Skeleton List:
.loading-skeleton-list/data-loading-state="list" - Skeleton Chart:
.loading-skeleton-chart/data-loading-state="chart" - Section shell:
.loading-skeleton-shell/data-loading-state="shell"
When submitting forms, action buttons display a loading spinner and transition text while disabling interactions:
import { Loader2 } from 'lucide-react';
export function SubmitButton({ pending }: { pending: boolean }) {
return (
<button
type="submit"
disabled={pending}
className="flex items-center justify-center w-full bg-brand.red hover:bg-brand.redHover text-white px-6 py-3 rounded-lg font-semibold transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{pending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
"Confirm Transfer"
)}
</button>
);
}- Error Handling Strategy — Covers global error boundaries and logger configurations.
- Form Action Hook Guide — Explains state transitions during AJAX form requests.
- Client API Guide — Explains
apiClientrequests, retry delays, and session expiry flows. - Status Semantics Handoff — Visual design specifications for semantic statuses.
- Component Naming Conventions — Naming and structure rules for UI components.
- Component Lifecycle — Handoff from design tokens to production components.