
Providing Feedback
- 58 installs
- 426 repo stars
- Updated December 11, 2025
- ancoleman/ai-design-components
Providing-feedback is a Claude skill that implements feedback and notification UI (toasts, alerts, modals, progress indicators, empty states) for communicating system state.
About
This skill implements feedback and notification UI including toasts, alerts, modals, progress indicators and empty states. Developers use it to communicate system state, confirm actions, or show errors consistently. It provides a decision matrix for choosing feedback type plus accessible React implementation patterns.
- Feedback-type decision matrix (modal, alert, toast, tooltip)
- React patterns with Sonner and Radix UI
- Accessible ARIA live regions and focus management
Providing Feedback by the numbers
- 58 all-time installs (skills.sh)
- Ranked #1,228 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
providing-feedback capabilities & compatibility
- Capabilities
- toast notifications · modal dialogs · progress indicators · empty states · accessible feedback
- Use cases
- frontend · ui design
- Runs
- Runs locally
- Pricing
- Free
What providing-feedback says it does
Implements feedback and notification systems including toasts, alerts, modals, progress indicators, and error states.
Critical + Blocking → Modal Dialog
npx skills add https://github.com/ancoleman/ai-design-components --skill providing-feedbackAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 426 |
| Last updated | December 11, 2025 |
| Repository | ancoleman/ai-design-components ↗ |
What it does
Implement consistent feedback UI: toasts, alerts, modals, progress indicators and error and empty states.
Who is it for?
Adding consistent, accessible notification and system-state UI to React apps.
Skip if: Non-UI backend services with no user-facing feedback surface.
When should I use this skill?
Communicating system state, displaying messages, confirming actions, or showing errors.
What you get
Consistent, accessible feedback components chosen by urgency and attention needs.
- Toast, modal and alert components
- Progress and empty-state patterns
- ARIA-based accessible feedback
By the numbers
- Toast stack limit 3-5 maximum
- Auto-dismiss durations from 3-4s success to 7-10s error
Files
Providing User Feedback and Notifications
This skill implements comprehensive feedback and notification systems that enhance all other component skills by providing consistent patterns for communicating system state, displaying messages, and handling user confirmations.
When to Use This Skill
Activate this skill when:
- Implementing toast notifications or snackbars
- Displaying success, error, warning, or info messages
- Creating modal dialogs or confirmation dialogs
- Implementing progress indicators (spinners, progress bars, skeleton screens)
- Designing empty states or zero-result displays
- Adding tooltips or contextual help
- Determining notification timing, stacking, or positioning
- Implementing accessible feedback patterns with ARIA
- Communicating any system state to users
Feedback Type Decision Matrix
Choose the appropriate feedback mechanism based on urgency and attention requirements:
Critical + Blocking → Modal Dialog
Important + Non-blocking → Alert Banner
Success/Info + Temporary → Toast/Snackbar
Contextual Help → Tooltip/Popover
In-progress → Progress Indicator
No Data → Empty StateQuick Reference by Urgency
| Urgency Level | Component | Duration | Blocks Interaction |
|---|---|---|---|
| Critical | Modal Dialog | Until action | Yes |
| Important | Alert Banner | Until dismissed | No |
| Standard | Toast | 3-7 seconds | No |
| Contextual | Inline Message | Persistent | No |
| Help | Tooltip | On hover | No |
| Progress | Spinner/Bar | During operation | Optional |
Implementation Approach
Step 1: Determine Feedback Type
Assess the situation using these criteria: 1. Urgency: How critical is the information? 2. Duration: How long should it persist? 3. Action Required: Does user need to respond? 4. Context: Is it related to specific UI element?
Step 2: Choose Implementation Pattern
For Toasts/Snackbars:
- Position: Bottom-right (recommended)
- Duration: 3-4s (success), 5-7s (warning), 7-10s (error)
- Stack limit: 3-5 maximum
- See
references/toast-patterns.mdfor detailed patterns
For Modal Dialogs:
- Focus management: Trap focus within modal
- Accessibility: ESC to close, proper ARIA labels
- Backdrop: Click outside to close (optional)
- See
references/modal-patterns.mdfor implementation
For Progress Indicators:
- <100ms: No indicator needed
- 100ms-5s: Spinner with message
- 5s-30s: Progress bar (determinate if possible)
- >30s: Progress bar + time estimate + cancel
- See
references/progress-indicators.mdfor patterns
For Empty States:
- Include: Illustration, headline, body text, CTA
- Types: First use, zero results, error, permission denied
- See
references/empty-states.mdfor designs
Step 3: Implement with Recommended Libraries
Modern React Stack (Recommended):
npm install sonner @radix-ui/react-dialogFor Toasts - Use Sonner:
import { Toaster, toast } from 'sonner';
// In your app root
<Toaster position="bottom-right" />
// Trigger notifications
toast.success('Changes saved successfully');
toast.promise(saveData(), {
loading: 'Saving...',
success: 'Saved!',
error: 'Failed to save'
});For Modals - Use Radix UI:
import * as Dialog from '@radix-ui/react-dialog';
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Confirm Action</Dialog.Title>
<Dialog.Description>Are you sure?</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>See references/library-comparison.md for alternative libraries and selection criteria.
Step 4: Apply Accessibility Patterns
ARIA Live Regions for Announcements:
<!-- For non-critical notifications -->
<div role="status" aria-live="polite">
File uploaded successfully
</div>
<!-- For critical alerts -->
<div role="alert" aria-live="assertive">
Error: Failed to save
</div>Focus Management for Modals: 1. Save current focus before opening 2. Move focus to first interactive element in modal 3. Trap focus within modal (Tab cycles) 4. Restore focus to trigger on close
See references/accessibility-feedback.md for complete patterns.
Step 5: Integrate Design Tokens
All feedback components use the design-tokens skill for consistent theming:
/* Example token usage */
.toast {
background: var(--toast-bg);
color: var(--toast-text);
padding: var(--toast-padding);
border-radius: var(--toast-border-radius);
box-shadow: var(--toast-shadow);
animation-duration: var(--toast-enter-duration);
}Token categories used:
- Colors: Toast, alert, modal, tooltip backgrounds
- Spacing: Internal padding for all components
- Typography: Font sizes for titles and messages
- Shadows: Elevation for floating elements
- Motion: Animation durations and easing
Notification Timing Guidelines
Auto-dismiss durations:
- Success: 3-4 seconds
- Info: 4-5 seconds
- Warning: 5-7 seconds
- Error: 7-10 seconds or manual dismiss
- With action button: 10+ seconds or no auto-dismiss
Progress indicator thresholds:
- <100ms: No indicator
- 100ms-5s: Spinner
- 5s-30s: Progress bar
- >30s: Progress bar + cancel option
Resources
Scripts (Token-Free Execution)
scripts/generate_toast_manager.js- Generate toast configurations with timing and stackingscripts/format_messages.py- Format user-facing messages based on contextscripts/calculate_timing.js- Calculate auto-dismiss timings
References (Detailed Documentation)
references/toast-patterns.md- Toast positioning, stacking, animationsreferences/alert-patterns.md- Alert banner implementationsreferences/modal-patterns.md- Modal dialogs with focus managementreferences/progress-indicators.md- Loading states and progressreferences/empty-states.md- No-data and zero-result patternsreferences/accessibility-feedback.md- ARIA patterns and focus managementreferences/library-comparison.md- Detailed library analysis
Examples (Implementation Code)
examples/success-toast.tsx- Success notification with Sonnerexamples/confirmation-modal.tsx- Delete confirmation with Radix UIexamples/progress-upload.tsx- File upload with progress barexamples/inline-validation.tsx- Form validation errors
Assets (Templates and Configs)
assets/message-templates.json- Reusable message templatesassets/error-catalog.json- Error code to message mappingsassets/timing-config.json- Timing recommendations
Cross-Skill Integration
This skill enhances all other component skills:
- Forms: Validation feedback, success confirmations
- Data Visualization: Loading states, error messages
- Tables: Bulk operation feedback, action confirmations
- AI Chat: Streaming indicators, rate limit warnings
- Dashboards: Widget loading, system status
- Search/Filter: Zero results, search progress
- Media: Upload progress, processing status
- Design Tokens: All visual styling via token system
Library Quick Comparison
| Library | Type | Size | Best For |
|---|---|---|---|
| Sonner | Toast | Small | Modern React 18+, accessibility |
| react-hot-toast | Toast | <5KB | Minimal bundle size |
| react-toastify | Toast | ~16KB | RTL support, mobile |
| Radix UI | Modal | Small | Design systems, headless |
| Headless UI | Modal | Small | Tailwind projects |
Choose based on project requirements. See references/library-comparison.md for detailed analysis.
Key Principles
1. Match urgency to attention: Don't use modals for non-critical info 2. Be consistent: Same feedback type for similar actions 3. Provide context: Explain what happened and what to do 4. Enable recovery: Include undo, retry, or help options 5. Respect preferences: Honor reduced motion settings 6. Test accessibility: Verify with screen readers and keyboard
{
"http": {
"400": {
"code": "BAD_REQUEST",
"message": "The request was invalid",
"userMessage": "Please check your input and try again",
"suggestions": [
"Verify all required fields are filled",
"Check for invalid characters",
"Ensure data formats are correct"
]
},
"401": {
"code": "UNAUTHORIZED",
"message": "Authentication required",
"userMessage": "Please sign in to continue",
"suggestions": [
"Sign in with your account",
"Check your credentials",
"Request a password reset if needed"
]
},
"403": {
"code": "FORBIDDEN",
"message": "Access denied",
"userMessage": "You don't have permission to access this resource",
"suggestions": [
"Contact your administrator",
"Request access permissions",
"Check if you're using the right account"
]
},
"404": {
"code": "NOT_FOUND",
"message": "Resource not found",
"userMessage": "We couldn't find what you're looking for",
"suggestions": [
"Check the URL or link",
"The item may have been moved or deleted",
"Try searching for it"
]
},
"409": {
"code": "CONFLICT",
"message": "Resource conflict",
"userMessage": "This conflicts with existing data",
"suggestions": [
"Refresh and try again",
"Check for duplicate entries",
"Someone else may have made changes"
]
},
"413": {
"code": "PAYLOAD_TOO_LARGE",
"message": "Request entity too large",
"userMessage": "The file or data is too large",
"suggestions": [
"Reduce file size",
"Split into smaller parts",
"Compress the data"
]
},
"422": {
"code": "UNPROCESSABLE_ENTITY",
"message": "Validation failed",
"userMessage": "Please fix the validation errors",
"suggestions": [
"Review highlighted fields",
"Check data formats",
"Ensure all requirements are met"
]
},
"429": {
"code": "TOO_MANY_REQUESTS",
"message": "Rate limit exceeded",
"userMessage": "Too many requests. Please wait a moment",
"suggestions": [
"Wait before trying again",
"Reduce request frequency",
"Consider upgrading your plan"
]
},
"500": {
"code": "INTERNAL_SERVER_ERROR",
"message": "Server error",
"userMessage": "Something went wrong on our end",
"suggestions": [
"Try again in a few moments",
"If the problem persists, contact support",
"Check our status page"
]
},
"502": {
"code": "BAD_GATEWAY",
"message": "Gateway error",
"userMessage": "We're having trouble connecting to our servers",
"suggestions": [
"Wait a moment and try again",
"Check your internet connection",
"The service may be temporarily unavailable"
]
},
"503": {
"code": "SERVICE_UNAVAILABLE",
"message": "Service unavailable",
"userMessage": "The service is temporarily unavailable",
"suggestions": [
"Try again in a few minutes",
"Check our status page for updates",
"We may be performing maintenance"
]
},
"504": {
"code": "GATEWAY_TIMEOUT",
"message": "Gateway timeout",
"userMessage": "The request took too long to process",
"suggestions": [
"Try again with less data",
"The server may be busy",
"Check your internet connection"
]
}
},
"application": {
"AUTH001": {
"message": "Invalid credentials",
"userMessage": "Email or password is incorrect",
"suggestions": [
"Check your email and password",
"Passwords are case-sensitive",
"Reset your password if forgotten"
]
},
"AUTH002": {
"message": "Session expired",
"userMessage": "Your session has expired. Please sign in again",
"suggestions": [
"Sign in again to continue",
"This happens for security reasons",
"Your work has been saved"
]
},
"AUTH003": {
"message": "Account locked",
"userMessage": "Your account has been temporarily locked",
"suggestions": [
"Too many failed login attempts",
"Wait 15 minutes and try again",
"Contact support if you need immediate access"
]
},
"FILE001": {
"message": "Invalid file type",
"userMessage": "This file type is not supported",
"suggestions": [
"Supported types: JPG, PNG, PDF, DOC",
"Convert your file to a supported format",
"Check file extension is correct"
]
},
"FILE002": {
"message": "File too large",
"userMessage": "File exceeds maximum size of {maxSize}",
"suggestions": [
"Compress or resize the file",
"Split into multiple smaller files",
"Upgrade for larger file limits"
]
},
"FILE003": {
"message": "File corrupted",
"userMessage": "The file appears to be corrupted",
"suggestions": [
"Try uploading again",
"Check the original file",
"Use a different file"
]
},
"DATA001": {
"message": "Duplicate entry",
"userMessage": "This item already exists",
"suggestions": [
"Use a different name",
"Edit the existing item instead",
"Check for typos"
]
},
"DATA002": {
"message": "Referenced item not found",
"userMessage": "A related item could not be found",
"suggestions": [
"The item may have been deleted",
"Refresh and try again",
"Select a different item"
]
},
"DATA003": {
"message": "Data integrity error",
"userMessage": "Data validation failed",
"suggestions": [
"Check all required fields",
"Ensure data formats are correct",
"Some fields may have changed"
]
},
"NETWORK001": {
"message": "No internet connection",
"userMessage": "You appear to be offline",
"suggestions": [
"Check your internet connection",
"Try again when connected",
"Some features work offline"
]
},
"NETWORK002": {
"message": "Connection timeout",
"userMessage": "The connection timed out",
"suggestions": [
"Check your internet speed",
"Try again with a stable connection",
"The server may be slow"
]
},
"PAYMENT001": {
"message": "Payment failed",
"userMessage": "We couldn't process your payment",
"suggestions": [
"Check your payment details",
"Ensure sufficient funds",
"Try a different payment method"
]
},
"PAYMENT002": {
"message": "Subscription expired",
"userMessage": "Your subscription has expired",
"suggestions": [
"Renew your subscription",
"Update payment method",
"Choose a different plan"
]
},
"LIMIT001": {
"message": "Storage limit reached",
"userMessage": "You've reached your storage limit",
"suggestions": [
"Delete unused files",
"Upgrade your storage plan",
"Download and remove old files"
]
},
"LIMIT002": {
"message": "API limit exceeded",
"userMessage": "You've exceeded your API limit",
"suggestions": [
"Wait until limit resets",
"Upgrade to a higher plan",
"Reduce API usage"
]
}
},
"validation": {
"REQUIRED_FIELD": {
"message": "Required field missing",
"userMessage": "{fieldName} is required",
"fieldLevel": true
},
"INVALID_EMAIL": {
"message": "Invalid email format",
"userMessage": "Please enter a valid email address",
"fieldLevel": true,
"pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"
},
"INVALID_PHONE": {
"message": "Invalid phone format",
"userMessage": "Please enter a valid phone number",
"fieldLevel": true,
"pattern": "^[\\+]?[\\d\\s\\-\\(\\)]+$"
},
"INVALID_URL": {
"message": "Invalid URL format",
"userMessage": "Please enter a valid URL",
"fieldLevel": true,
"pattern": "^https?:\\/\\/.+"
},
"PASSWORD_TOO_WEAK": {
"message": "Password doesn't meet requirements",
"userMessage": "Password must be at least 8 characters with uppercase, lowercase, and numbers",
"fieldLevel": true
},
"PASSWORDS_DONT_MATCH": {
"message": "Password confirmation failed",
"userMessage": "Passwords don't match",
"fieldLevel": true
},
"VALUE_TOO_SHORT": {
"message": "Value too short",
"userMessage": "Must be at least {minLength} characters",
"fieldLevel": true
},
"VALUE_TOO_LONG": {
"message": "Value too long",
"userMessage": "Must be no more than {maxLength} characters",
"fieldLevel": true
},
"INVALID_DATE": {
"message": "Invalid date",
"userMessage": "Please enter a valid date",
"fieldLevel": true
},
"DATE_IN_PAST": {
"message": "Date cannot be in the past",
"userMessage": "Please select a future date",
"fieldLevel": true
},
"DATE_IN_FUTURE": {
"message": "Date cannot be in the future",
"userMessage": "Please select a past date",
"fieldLevel": true
},
"INVALID_NUMBER": {
"message": "Invalid number",
"userMessage": "Please enter a valid number",
"fieldLevel": true
},
"NUMBER_OUT_OF_RANGE": {
"message": "Number out of range",
"userMessage": "Must be between {min} and {max}",
"fieldLevel": true
}
}
}{
"success": {
"create": {
"default": "{item} created successfully",
"withId": "{item} created with ID: {id}",
"withName": "'{name}' created successfully"
},
"update": {
"default": "{item} updated successfully",
"withChanges": "{count} changes saved",
"withTime": "Last updated at {time}"
},
"delete": {
"default": "{item} deleted successfully",
"withUndo": "{item} deleted. Undo",
"permanent": "{item} permanently deleted"
},
"save": {
"default": "Changes saved",
"withLocation": "Saved to {location}",
"autosave": "Autosaved at {time}"
},
"upload": {
"single": "{filename} uploaded successfully",
"multiple": "{count} files uploaded",
"withSize": "{filename} ({size}) uploaded"
},
"download": {
"started": "Download started",
"complete": "{filename} downloaded",
"multiple": "{count} files downloaded"
},
"copy": {
"default": "Copied to clipboard",
"withContent": "'{content}' copied",
"link": "Link copied to clipboard"
},
"share": {
"default": "Shared successfully",
"withUsers": "Shared with {count} users",
"public": "Public link created"
},
"sync": {
"default": "Sync complete",
"withDevices": "Synced across {count} devices",
"withTime": "Last synced: {time}"
}
},
"error": {
"network": {
"offline": "No internet connection",
"timeout": "Request timed out",
"serverError": "Server error. Please try again",
"notFound": "Resource not found"
},
"auth": {
"unauthorized": "Please sign in to continue",
"forbidden": "You don't have permission",
"sessionExpired": "Session expired. Please sign in again",
"invalidCredentials": "Invalid email or password"
},
"validation": {
"required": "{field} is required",
"invalidFormat": "Invalid {field} format",
"tooLong": "{field} is too long",
"tooShort": "{field} is too short",
"invalidEmail": "Please enter a valid email",
"invalidPhone": "Please enter a valid phone number",
"invalidUrl": "Please enter a valid URL",
"passwordMismatch": "Passwords don't match"
},
"file": {
"tooLarge": "File size exceeds {maxSize}",
"invalidType": "Invalid file type. Accepted: {types}",
"uploadFailed": "Upload failed. Please try again",
"processingFailed": "Failed to process file"
},
"data": {
"saveFailed": "Failed to save changes",
"loadFailed": "Failed to load data",
"deleteFailed": "Failed to delete item",
"conflictError": "Conflict detected. Please refresh"
},
"limit": {
"rateLimited": "Too many requests. Please wait",
"quotaExceeded": "Quota exceeded",
"storageLimit": "Storage limit reached"
}
},
"warning": {
"unsavedChanges": "You have unsaved changes",
"deleteConfirm": "Are you sure you want to delete {item}?",
"permanentAction": "This action cannot be undone",
"largeOperation": "This may take several minutes",
"deprecation": "This feature will be removed soon",
"compatibility": "Some features may not work in your browser",
"slowConnection": "Slow connection detected",
"limitApproaching": "You're approaching your {limit} limit",
"maintenanceScheduled": "Maintenance scheduled for {time}"
},
"info": {
"loading": {
"default": "Loading...",
"withItem": "Loading {item}...",
"percentage": "Loading... {progress}%"
},
"processing": {
"default": "Processing...",
"withEstimate": "Processing... About {time} remaining",
"withStep": "Processing step {current} of {total}"
},
"searching": {
"default": "Searching...",
"withQuery": "Searching for '{query}'...",
"withCount": "Found {count} results"
},
"empty": {
"default": "No items found",
"search": "No results for '{query}'",
"filter": "No items match your filters",
"firstUse": "Get started by creating your first {item}"
},
"help": {
"keyboard": "Press {key} for keyboard shortcuts",
"drag": "Drag to reorder",
"click": "Click to select",
"hover": "Hover for more info"
},
"status": {
"online": "Connected",
"offline": "Working offline",
"syncing": "Syncing...",
"saved": "All changes saved",
"draft": "Draft",
"published": "Published"
}
},
"confirmation": {
"titles": {
"delete": "Delete {item}?",
"remove": "Remove {item}?",
"cancel": "Cancel {action}?",
"discard": "Discard changes?",
"logout": "Sign out?",
"leave": "Leave page?"
},
"messages": {
"deleteItem": "This will permanently delete {item}. This action cannot be undone.",
"discardChanges": "All unsaved changes will be lost.",
"cancelOperation": "The current operation will be cancelled and any progress will be lost.",
"logout": "You will be signed out from all devices.",
"bulkAction": "This will affect {count} items."
},
"actions": {
"confirmDelete": "Delete",
"confirmRemove": "Remove",
"confirmDiscard": "Discard",
"confirmCancel": "Cancel Operation",
"confirmLogout": "Sign Out",
"confirmLeave": "Leave",
"cancel": "Cancel",
"goBack": "Go Back",
"saveFirst": "Save First"
}
},
"progress": {
"upload": {
"starting": "Starting upload...",
"uploading": "Uploading {filename}... {progress}%",
"processing": "Processing uploaded file...",
"complete": "Upload complete"
},
"download": {
"preparing": "Preparing download...",
"downloading": "Downloading... {progress}%",
"complete": "Download complete"
},
"install": {
"checking": "Checking for updates...",
"downloading": "Downloading update...",
"installing": "Installing...",
"complete": "Installation complete"
},
"export": {
"preparing": "Preparing export...",
"generating": "Generating {format} file...",
"compressing": "Compressing files...",
"complete": "Export ready"
}
},
"accessibility": {
"announcements": {
"error": "Error: {message}",
"warning": "Warning: {message}",
"success": "Success: {message}",
"info": "Information: {message}",
"loading": "Loading {item}, please wait",
"loaded": "{item} loaded",
"updated": "{item} updated",
"deleted": "{item} deleted"
},
"descriptions": {
"close": "Close notification",
"dismiss": "Dismiss message",
"expand": "Show more information",
"collapse": "Show less information",
"retry": "Try again",
"undo": "Undo last action"
}
}
}{
"toast": {
"durations": {
"success": {
"base": 3000,
"min": 2000,
"max": 5000,
"description": "Brief positive confirmation"
},
"info": {
"base": 4000,
"min": 3000,
"max": 6000,
"description": "Informational message"
},
"warning": {
"base": 5000,
"min": 4000,
"max": 7000,
"description": "Important notice requiring attention"
},
"error": {
"base": 7000,
"min": 5000,
"max": 10000,
"description": "Error message requiring reading time"
},
"withAction": {
"base": null,
"min": 10000,
"max": null,
"description": "Never auto-dismiss when action button present"
}
},
"readingSpeed": {
"fast": 250,
"average": 200,
"slow": 150,
"unit": "words per minute"
},
"stacking": {
"maxVisible": 5,
"spacing": 12,
"strategy": {
"queue": {
"maxVisible": 1,
"showNext": "after-dismiss"
},
"stack": {
"maxVisible": 5,
"showNext": "immediate"
},
"replace": {
"maxVisible": 1,
"showNext": "replace"
}
}
},
"animations": {
"enter": {
"duration": 300,
"easing": "ease-out"
},
"exit": {
"duration": 200,
"easing": "ease-in"
},
"update": {
"duration": 150,
"easing": "ease-in-out"
}
}
},
"modal": {
"animations": {
"backdrop": {
"enter": 200,
"exit": 150
},
"content": {
"enter": 200,
"exit": 150
},
"scale": {
"from": 0.95,
"to": 1
}
},
"dismissible": {
"clickOutside": true,
"escapeKey": true,
"exceptions": ["critical", "confirmation"]
}
},
"progress": {
"thresholds": {
"immediate": {
"max": 100,
"indicator": "none",
"description": "No indicator needed"
},
"quick": {
"min": 100,
"max": 1000,
"indicator": "spinner-small",
"description": "Small spinner only"
},
"moderate": {
"min": 1000,
"max": 5000,
"indicator": "spinner-with-text",
"description": "Spinner with loading message"
},
"long": {
"min": 5000,
"max": 30000,
"indicator": "progress-bar",
"description": "Determinate progress bar if possible"
},
"veryLong": {
"min": 30000,
"max": null,
"indicator": "progress-bar-with-cancel",
"description": "Progress bar with time estimate and cancel option"
}
},
"updates": {
"spinner": {
"showElapsedAfter": 10000,
"updateInterval": 1000
},
"progressBar": {
"updateInterval": 250,
"smoothTransition": true
},
"timeEstimate": {
"showAfter": 5000,
"updateInterval": 1000,
"algorithm": "moving-average"
}
}
},
"alert": {
"dismissTiming": {
"info": {
"autoDismiss": false,
"userDismissible": true
},
"success": {
"autoDismiss": 5000,
"userDismissible": true
},
"warning": {
"autoDismiss": false,
"userDismissible": true
},
"error": {
"autoDismiss": false,
"userDismissible": false,
"dismissAfterAction": true
}
}
},
"emptyState": {
"animations": {
"enter": {
"duration": 400,
"stagger": 100,
"easing": "ease-out"
},
"illustration": {
"float": {
"enabled": true,
"duration": 3000,
"distance": 10
}
}
}
},
"accessibility": {
"announcements": {
"debounce": 500,
"priority": {
"error": "assertive",
"warning": "assertive",
"success": "polite",
"info": "polite"
}
},
"focus": {
"trapDelay": 0,
"restoreDelay": 0,
"autoFocus": {
"modal": true,
"alert": false,
"toast": false
}
},
"reducedMotion": {
"detect": true,
"overrides": {
"animations": 0,
"transitions": 0.01,
"smoothScroll": false
}
}
},
"mobile": {
"toast": {
"position": "bottom-center",
"fullWidth": true,
"swipeToDismiss": true,
"swipeThreshold": 100
},
"modal": {
"fullScreen": true,
"slideUp": true,
"dragToDismiss": true,
"dragThreshold": 150
},
"touchTargets": {
"minSize": 44,
"recommendedSize": 48,
"unit": "pixels"
}
},
"performance": {
"batchUpdates": {
"enabled": true,
"debounce": 16,
"maxBatch": 10
},
"virtualScrolling": {
"enabled": true,
"threshold": 50,
"overscan": 3
},
"lazyLoading": {
"enabled": true,
"threshold": 100,
"rootMargin": "50px"
}
},
"defaults": {
"position": "bottom-right",
"theme": "auto",
"sound": false,
"vibration": false,
"persistence": {
"saveState": false,
"clearOnReload": true
}
}
}import React, { useState, useRef, useEffect } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { Transition } from '@headlessui/react';
/**
* Confirmation Modal Implementation Examples
*
* Demonstrates deletion confirmations and other critical actions
* using both Radix UI and Headless UI
*/
// Radix UI Confirmation Modal
export function RadixConfirmationModal() {
const [itemToDelete, setItemToDelete] = useState(null);
const handleDelete = async () => {
if (!itemToDelete) return;
try {
// Perform deletion
await deleteItem(itemToDelete.id);
toast.success(`${itemToDelete.name} deleted successfully`);
setItemToDelete(null);
} catch (error) {
toast.error('Failed to delete item');
}
};
return (
<Dialog.Root open={!!itemToDelete} onOpenChange={(open) => !open && setItemToDelete(null)}>
<Dialog.Portal>
<Dialog.Overlay className="modal-overlay" />
<Dialog.Content className="modal-content">
<Dialog.Title className="modal-title">
Confirm Deletion
</Dialog.Title>
<Dialog.Description className="modal-description">
Are you sure you want to delete "{itemToDelete?.name}"?
This action cannot be undone.
</Dialog.Description>
<div className="modal-actions">
<Dialog.Close asChild>
<button className="btn-secondary">
Cancel
</button>
</Dialog.Close>
<button
onClick={handleDelete}
className="btn-danger"
autoFocus
>
Delete
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
// Headless UI Confirmation Modal
export function HeadlessConfirmationModal({ isOpen, onClose, onConfirm, item }) {
const [isDeleting, setIsDeleting] = useState(false);
const cancelButtonRef = useRef(null);
const handleConfirm = async () => {
setIsDeleting(true);
try {
await onConfirm();
onClose();
} catch (error) {
console.error('Deletion failed:', error);
} finally {
setIsDeleting(false);
}
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog
as="div"
className="relative z-50"
initialFocus={cancelButtonRef}
onClose={onClose}
>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<Dialog.Title
as="h3"
className="text-base font-semibold leading-6 text-gray-900"
>
Delete {item?.type || 'item'}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete "{item?.name}"?
All of the data will be permanently removed.
This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<button
type="button"
className="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto"
onClick={handleConfirm}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
<button
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto"
onClick={onClose}
ref={cancelButtonRef}
disabled={isDeleting}
>
Cancel
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}
// Custom Confirmation Modal with Focus Management
export function CustomConfirmationModal({
isOpen,
onClose,
onConfirm,
title = 'Confirm Action',
message = 'Are you sure you want to proceed?',
confirmText = 'Confirm',
cancelText = 'Cancel',
type = 'warning' // 'warning', 'danger', 'info'
}) {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
const confirmButtonRef = useRef(null);
// Focus management
useEffect(() => {
if (isOpen) {
// Save current focus
previousFocusRef.current = document.activeElement;
// Focus confirm button (most dangerous action)
setTimeout(() => {
confirmButtonRef.current?.focus();
}, 0);
// Add ESC key handler
const handleEscape = (e) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('keydown', handleEscape);
};
} else {
// Restore focus when closing
previousFocusRef.current?.focus();
}
}, [isOpen, onClose]);
if (!isOpen) return null;
const getIconForType = () => {
switch (type) {
case 'danger':
return '⚠️';
case 'warning':
return '⚠';
case 'info':
return 'ℹ';
default:
return '?';
}
};
const getColorForType = () => {
switch (type) {
case 'danger':
return 'red';
case 'warning':
return 'yellow';
case 'info':
return 'blue';
default:
return 'gray';
}
};
return (
<div
className="modal-root"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
ref={modalRef}
>
<div
className="modal-backdrop"
onClick={onClose}
aria-hidden="true"
/>
<div className="modal-container">
<div className={`modal-icon modal-icon-${type}`}>
{getIconForType()}
</div>
<h2 id="modal-title" className="modal-title">
{title}
</h2>
<p id="modal-description" className="modal-description">
{message}
</p>
<div className="modal-actions">
<button
onClick={onClose}
className="btn-secondary"
>
{cancelText}
</button>
<button
ref={confirmButtonRef}
onClick={onConfirm}
className={`btn-primary btn-${type}`}
>
{confirmText}
</button>
</div>
</div>
</div>
);
}
// Unsaved Changes Confirmation
export function UnsavedChangesModal({ isOpen, onClose, onDiscard, onSave }) {
return (
<Dialog.Root open={isOpen} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className="modal-overlay" />
<Dialog.Content className="modal-content">
<Dialog.Title>Unsaved Changes</Dialog.Title>
<Dialog.Description>
You have unsaved changes. What would you like to do?
</Dialog.Description>
<div className="modal-actions-three">
<Dialog.Close asChild>
<button className="btn-secondary">
Cancel
</button>
</Dialog.Close>
<button
onClick={onDiscard}
className="btn-danger-outline"
>
Discard Changes
</button>
<button
onClick={onSave}
className="btn-primary"
autoFocus
>
Save Changes
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
// Bulk Action Confirmation
export function BulkActionModal({ isOpen, onClose, onConfirm, selectedItems, action }) {
const itemCount = selectedItems?.length || 0;
const itemLabel = itemCount === 1 ? 'item' : 'items';
const getActionMessage = () => {
switch (action) {
case 'delete':
return `permanently delete ${itemCount} ${itemLabel}`;
case 'archive':
return `archive ${itemCount} ${itemLabel}`;
case 'export':
return `export ${itemCount} ${itemLabel}`;
default:
return `perform this action on ${itemCount} ${itemLabel}`;
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className="modal-overlay" />
<Dialog.Content className="modal-content">
<Dialog.Title>Confirm Bulk Action</Dialog.Title>
<Dialog.Description>
You are about to {getActionMessage()}.
</Dialog.Description>
{itemCount > 5 && (
<div className="modal-warning">
<InfoIcon />
<span>This is a large number of items and may take some time.</span>
</div>
)}
<div className="selected-items-preview">
<h4>Selected Items:</h4>
<ul className="item-list">
{selectedItems?.slice(0, 5).map(item => (
<li key={item.id}>{item.name}</li>
))}
{itemCount > 5 && (
<li className="more-items">...and {itemCount - 5} more</li>
)}
</ul>
</div>
<div className="modal-actions">
<Dialog.Close asChild>
<button className="btn-secondary">
Cancel
</button>
</Dialog.Close>
<button
onClick={() => onConfirm(selectedItems)}
className={`btn-primary ${action === 'delete' ? 'btn-danger' : ''}`}
>
{action === 'delete' ? 'Delete All' : 'Confirm'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
// Styles for confirmation modals
const styles = `
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
animation: overlay-fade-in 200ms ease-out;
}
.modal-content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 8px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
animation: modal-enter 200ms ease-out;
}
.modal-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.modal-description {
color: #6b7280;
margin-bottom: 24px;
line-height: 1.5;
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.modal-actions-three {
display: flex;
gap: 12px;
justify-content: space-between;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-danger-outline {
border: 1px solid #ef4444;
color: #ef4444;
}
.modal-warning {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: #fef3c7;
border: 1px solid #fbbf24;
border-radius: 6px;
margin-bottom: 16px;
color: #92400e;
}
.selected-items-preview {
background: #f9fafb;
padding: 12px;
border-radius: 6px;
margin-bottom: 24px;
}
.item-list {
list-style: none;
padding: 0;
margin: 8px 0 0 0;
}
.item-list li {
padding: 4px 0;
color: #4b5563;
}
.more-items {
font-style: italic;
color: #9ca3af;
}
@keyframes overlay-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-enter {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.modal-overlay,
.modal-content {
animation: none;
}
}
`;import React, { useState, useEffect } from 'react';
/**
* Inline Form Validation Examples
*
* Demonstrates real-time form validation with inline error messages
* and field-level feedback
*/
// Real-time Form Validation Component
export function FormWithInlineValidation() {
const [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: '',
username: '',
phone: '',
website: ''
});
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Validation rules
const validationRules = {
email: {
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email address'
},
password: {
required: true,
minLength: 8,
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: 'Password must be at least 8 characters with uppercase, lowercase, and number'
},
confirmPassword: {
required: true,
match: 'password',
message: 'Passwords do not match'
},
username: {
required: true,
minLength: 3,
maxLength: 20,
pattern: /^[a-zA-Z0-9_]+$/,
message: 'Username must be 3-20 characters, alphanumeric and underscore only'
},
phone: {
pattern: /^\+?[\d\s-()]+$/,
minLength: 10,
message: 'Please enter a valid phone number'
},
website: {
pattern: /^https?:\/\/.+\..+/,
message: 'Please enter a valid URL starting with http:// or https://'
}
};
// Validate single field
const validateField = (name, value) => {
const rules = validationRules[name];
if (!rules) return '';
// Required check
if (rules.required && !value) {
return `${name.charAt(0).toUpperCase() + name.slice(1)} is required`;
}
// Pattern check
if (rules.pattern && value && !rules.pattern.test(value)) {
return rules.message;
}
// Min length check
if (rules.minLength && value && value.length < rules.minLength) {
return `Must be at least ${rules.minLength} characters`;
}
// Max length check
if (rules.maxLength && value && value.length > rules.maxLength) {
return `Must be no more than ${rules.maxLength} characters`;
}
// Match check (for confirm password)
if (rules.match && value !== formData[rules.match]) {
return rules.message;
}
return '';
};
// Handle input change
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Real-time validation if field has been touched
if (touched[name]) {
const error = validateField(name, value);
setErrors(prev => ({ ...prev, [name]: error }));
}
};
// Handle field blur
const handleBlur = (e) => {
const { name, value } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
// Validate on blur
const error = validateField(name, value);
setErrors(prev => ({ ...prev, [name]: error }));
};
// Validate entire form
const validateForm = () => {
const newErrors = {};
Object.keys(formData).forEach(key => {
const error = validateField(key, formData[key]);
if (error) newErrors[key] = error;
});
return newErrors;
};
// Handle form submission
const handleSubmit = async (e) => {
e.preventDefault();
// Mark all fields as touched
const allTouched = {};
Object.keys(formData).forEach(key => {
allTouched[key] = true;
});
setTouched(allTouched);
// Validate all fields
const newErrors = validateForm();
setErrors(newErrors);
if (Object.keys(newErrors).length === 0) {
setIsSubmitting(true);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
console.log('Form submitted:', formData);
// Show success message
toast.success('Form submitted successfully!');
setIsSubmitting(false);
} else {
// Focus first error field
const firstErrorField = Object.keys(newErrors)[0];
document.getElementById(firstErrorField)?.focus();
// Show error toast
toast.error('Please fix the errors before submitting');
}
};
return (
<form onSubmit={handleSubmit} className="validation-form">
<h2>Registration Form</h2>
{/* Email Field */}
<FormField
id="email"
label="Email Address"
type="email"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
error={errors.email}
touched={touched.email}
required
/>
{/* Username Field */}
<FormField
id="username"
label="Username"
type="text"
value={formData.username}
onChange={handleChange}
onBlur={handleBlur}
error={errors.username}
touched={touched.username}
required
helpText="3-20 characters, letters, numbers, and underscore only"
/>
{/* Password Field */}
<FormField
id="password"
label="Password"
type="password"
value={formData.password}
onChange={handleChange}
onBlur={handleBlur}
error={errors.password}
touched={touched.password}
required
strength={calculatePasswordStrength(formData.password)}
/>
{/* Confirm Password Field */}
<FormField
id="confirmPassword"
label="Confirm Password"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
onBlur={handleBlur}
error={errors.confirmPassword}
touched={touched.confirmPassword}
required
/>
{/* Phone Field (Optional) */}
<FormField
id="phone"
label="Phone Number"
type="tel"
value={formData.phone}
onChange={handleChange}
onBlur={handleBlur}
error={errors.phone}
touched={touched.phone}
helpText="Optional"
/>
{/* Website Field (Optional) */}
<FormField
id="website"
label="Website"
type="url"
value={formData.website}
onChange={handleChange}
onBlur={handleBlur}
error={errors.website}
touched={touched.website}
helpText="Optional - Include http:// or https://"
/>
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting}
className="btn-primary submit-btn"
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
// Reusable Form Field Component
function FormField({
id,
label,
type = 'text',
value,
onChange,
onBlur,
error,
touched,
required,
helpText,
strength
}) {
const hasError = touched && error;
const isValid = touched && !error && value;
return (
<div className={`form-field ${hasError ? 'field-error' : ''} ${isValid ? 'field-valid' : ''}`}>
<label htmlFor={id} className="field-label">
{label}
{required && <span className="required-mark">*</span>}
</label>
<div className="field-wrapper">
<input
id={id}
name={id}
type={type}
value={value}
onChange={onChange}
onBlur={onBlur}
className="field-input"
aria-invalid={hasError}
aria-describedby={hasError ? `${id}-error` : helpText ? `${id}-help` : undefined}
/>
{/* Status icon */}
<div className="field-icon">
{hasError && <span className="icon-error">✕</span>}
{isValid && <span className="icon-success">✓</span>}
</div>
</div>
{/* Password strength indicator */}
{strength !== undefined && value && (
<PasswordStrength strength={strength} />
)}
{/* Help text */}
{helpText && !hasError && (
<div id={`${id}-help`} className="field-help">
{helpText}
</div>
)}
{/* Error message */}
{hasError && (
<div
id={`${id}-error`}
className="field-error-message"
role="alert"
aria-live="polite"
>
{error}
</div>
)}
</div>
);
}
// Password Strength Indicator
function PasswordStrength({ strength }) {
const getStrengthLabel = () => {
if (strength < 2) return 'Weak';
if (strength < 3) return 'Fair';
if (strength < 4) return 'Good';
return 'Strong';
};
const getStrengthColor = () => {
if (strength < 2) return '#ef4444';
if (strength < 3) return '#f59e0b';
if (strength < 4) return '#3b82f6';
return '#10b981';
};
return (
<div className="password-strength">
<div className="strength-bars">
{[1, 2, 3, 4].map(level => (
<div
key={level}
className={`strength-bar ${strength >= level ? 'active' : ''}`}
style={{
backgroundColor: strength >= level ? getStrengthColor() : '#e5e7eb'
}}
/>
))}
</div>
<span className="strength-label" style={{ color: getStrengthColor() }}>
{getStrengthLabel()}
</span>
</div>
);
}
// Calculate password strength
function calculatePasswordStrength(password) {
if (!password) return 0;
let strength = 0;
// Length
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
// Character types
if (/[a-z]/.test(password)) strength += 0.5;
if (/[A-Z]/.test(password)) strength += 0.5;
if (/\d/.test(password)) strength += 0.5;
if (/[^a-zA-Z0-9]/.test(password)) strength += 0.5;
return Math.min(4, strength);
}
// Async Validation Example (Username availability)
export function AsyncValidationField() {
const [username, setUsername] = useState('');
const [isChecking, setIsChecking] = useState(false);
const [isAvailable, setIsAvailable] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
if (!username || username.length < 3) {
setIsAvailable(null);
setError('');
return;
}
const timer = setTimeout(async () => {
setIsChecking(true);
setError('');
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Simulate availability check
const unavailableUsernames = ['admin', 'test', 'user', 'demo'];
const available = !unavailableUsernames.includes(username.toLowerCase());
setIsAvailable(available);
if (!available) {
setError('Username is already taken');
}
setIsChecking(false);
}, 500); // Debounce for 500ms
return () => clearTimeout(timer);
}, [username]);
return (
<div className="form-field">
<label htmlFor="async-username" className="field-label">
Choose Username
</label>
<div className="field-wrapper">
<input
id="async-username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="field-input"
placeholder="Enter desired username"
/>
<div className="field-icon">
{isChecking && <span className="spinner-small">⟳</span>}
{!isChecking && isAvailable === true && <span className="icon-success">✓</span>}
{!isChecking && isAvailable === false && <span className="icon-error">✕</span>}
</div>
</div>
{isChecking && (
<div className="field-help">Checking availability...</div>
)}
{!isChecking && isAvailable === true && (
<div className="field-success">Username is available!</div>
)}
{!isChecking && error && (
<div className="field-error-message" role="alert">
{error}
</div>
)}
</div>
);
}
// Dynamic Field Validation (Credit Card)
export function CreditCardValidation() {
const [cardNumber, setCardNumber] = useState('');
const [cardType, setCardType] = useState(null);
const detectCardType = (number) => {
const cleanNumber = number.replace(/\s/g, '');
if (/^4/.test(cleanNumber)) return 'visa';
if (/^5[1-5]/.test(cleanNumber)) return 'mastercard';
if (/^3[47]/.test(cleanNumber)) return 'amex';
if (/^6(?:011|5)/.test(cleanNumber)) return 'discover';
return null;
};
const formatCardNumber = (value) => {
const cleanValue = value.replace(/\s/g, '');
const chunks = cleanValue.match(/.{1,4}/g) || [];
return chunks.join(' ');
};
const handleCardNumberChange = (e) => {
const value = e.target.value.replace(/\s/g, '');
if (/^\d*$/.test(value) && value.length <= 16) {
const formatted = formatCardNumber(value);
setCardNumber(formatted);
setCardType(detectCardType(value));
}
};
return (
<div className="form-field">
<label htmlFor="card-number" className="field-label">
Card Number
</label>
<div className="field-wrapper">
<input
id="card-number"
type="text"
value={cardNumber}
onChange={handleCardNumberChange}
placeholder="1234 5678 9012 3456"
className="field-input"
maxLength={19}
/>
{cardType && (
<div className="card-type-icon">
{cardType === 'visa' && '💳 Visa'}
{cardType === 'mastercard' && '💳 Mastercard'}
{cardType === 'amex' && '💳 Amex'}
{cardType === 'discover' && '💳 Discover'}
</div>
)}
</div>
{cardNumber.length === 19 && (
<div className="field-success">Valid card number format</div>
)}
</div>
);
}
// Styles
const styles = `
.validation-form {
max-width: 500px;
margin: 0 auto;
padding: 24px;
}
.form-field {
margin-bottom: 20px;
}
.field-label {
display: block;
margin-bottom: 6px;
font-weight: 500;
font-size: 14px;
color: #374151;
}
.required-mark {
color: #ef4444;
margin-left: 4px;
}
.field-wrapper {
position: relative;
}
.field-input {
width: 100%;
padding: 8px 36px 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 16px;
transition: all 0.2s;
}
.field-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.field-error .field-input {
border-color: #ef4444;
background-color: #fef2f2;
}
.field-error .field-input:focus {
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.field-valid .field-input {
border-color: #10b981;
background-color: #f0fdf4;
}
.field-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
}
.icon-error {
color: #ef4444;
}
.icon-success {
color: #10b981;
}
.field-help {
margin-top: 4px;
font-size: 14px;
color: #6b7280;
}
.field-error-message {
margin-top: 4px;
font-size: 14px;
color: #ef4444;
display: flex;
align-items: center;
gap: 4px;
}
.field-success {
margin-top: 4px;
font-size: 14px;
color: #10b981;
}
/* Password strength */
.password-strength {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.strength-bars {
display: flex;
gap: 4px;
flex: 1;
}
.strength-bar {
height: 4px;
flex: 1;
border-radius: 2px;
transition: background-color 0.3s;
}
.strength-label {
font-size: 12px;
font-weight: 500;
}
/* Spinner */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner-small {
display: inline-block;
animation: spin 1s linear infinite;
}
/* Submit button */
.submit-btn {
width: 100%;
padding: 12px;
margin-top: 24px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn:hover:not(:disabled) {
background: #2563eb;
}
.submit-btn:disabled {
background: #9ca3af;
cursor: not-allowed;
}
/* Card type icon */
.card-type-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
font-size: 14px;
}
`;import React, { useState, useEffect, useRef } from 'react';
/**
* File Upload Progress Examples
*
* Demonstrates various progress indicators for file uploads
* including single and multiple file upload with progress tracking
*/
// Single File Upload with Progress
export function FileUploadProgress({ file, onComplete, onError }) {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState('pending'); // pending, uploading, complete, error
const [uploadSpeed, setUploadSpeed] = useState(0);
const [timeRemaining, setTimeRemaining] = useState(null);
const xhrRef = useRef(null);
const startTimeRef = useRef(null);
const lastProgressRef = useRef({ loaded: 0, time: Date.now() });
useEffect(() => {
if (status === 'pending') {
startUpload();
}
return () => {
// Cleanup: abort upload if component unmounts
if (xhrRef.current) {
xhrRef.current.abort();
}
};
}, []);
const startUpload = () => {
setStatus('uploading');
startTimeRef.current = Date.now();
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
// Track upload progress
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
setProgress(Math.round(percentComplete));
// Calculate upload speed
const currentTime = Date.now();
const timeDiff = (currentTime - lastProgressRef.current.time) / 1000; // seconds
const bytesDiff = e.loaded - lastProgressRef.current.loaded;
const speed = bytesDiff / timeDiff; // bytes per second
setUploadSpeed(speed);
// Calculate time remaining
const remaining = e.total - e.loaded;
const timeLeft = remaining / speed; // seconds
setTimeRemaining(Math.round(timeLeft));
lastProgressRef.current = { loaded: e.loaded, time: currentTime };
}
});
// Handle completion
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
setStatus('complete');
setProgress(100);
onComplete?.(xhr.response);
} else {
setStatus('error');
onError?.(new Error(`Upload failed with status ${xhr.status}`));
}
});
// Handle errors
xhr.addEventListener('error', () => {
setStatus('error');
onError?.(new Error('Network error during upload'));
});
// Prepare and send request
const formData = new FormData();
formData.append('file', file);
xhr.open('POST', '/api/upload');
xhr.send(formData);
};
const handleCancel = () => {
if (xhrRef.current) {
xhrRef.current.abort();
setStatus('cancelled');
}
};
const handleRetry = () => {
setProgress(0);
setStatus('pending');
startUpload();
};
const formatFileSize = (bytes) => {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
const formatTime = (seconds) => {
if (!seconds || seconds < 0) return '';
if (seconds < 60) return `${seconds}s remaining`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${seconds % 60}s remaining`;
};
const formatSpeed = (bytesPerSecond) => {
return `${formatFileSize(bytesPerSecond)}/s`;
};
return (
<div className="upload-progress-container">
{/* File info */}
<div className="upload-header">
<div className="file-icon">
{getFileIcon(file.type)}
</div>
<div className="file-info">
<div className="file-name">{file.name}</div>
<div className="file-meta">
<span>{formatFileSize(file.size)}</span>
{status === 'uploading' && uploadSpeed > 0 && (
<>
<span className="separator">•</span>
<span>{formatSpeed(uploadSpeed)}</span>
<span className="separator">•</span>
<span>{formatTime(timeRemaining)}</span>
</>
)}
</div>
</div>
<div className="upload-actions">
{status === 'uploading' && (
<button onClick={handleCancel} className="btn-icon">
✕
</button>
)}
{status === 'error' && (
<button onClick={handleRetry} className="btn-icon">
↻
</button>
)}
{status === 'complete' && (
<span className="success-icon">✓</span>
)}
</div>
</div>
{/* Progress bar */}
<div className="progress-container">
<div className="progress-track">
<div
className={`progress-bar progress-${status}`}
style={{ width: `${progress}%` }}
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
<span className="progress-label">{progress}%</span>
</div>
{/* Status message */}
{status === 'error' && (
<div className="upload-error">
Upload failed. <button onClick={handleRetry}>Try again</button>
</div>
)}
{status === 'complete' && (
<div className="upload-success">
Upload complete!
</div>
)}
</div>
);
}
// Multiple File Upload Queue
export function MultiFileUploadQueue({ files, onAllComplete }) {
const [uploads, setUploads] = useState(
files.map((file, index) => ({
id: `${file.name}-${index}`,
file,
progress: 0,
status: 'queued', // queued, uploading, complete, error
speed: 0,
error: null
}))
);
const [currentUploadIndex, setCurrentUploadIndex] = useState(0);
const [parallelUploads, setParallelUploads] = useState(3); // Upload 3 files at once
useEffect(() => {
// Start initial uploads
for (let i = 0; i < Math.min(parallelUploads, files.length); i++) {
if (uploads[i]?.status === 'queued') {
startUpload(i);
}
}
}, []);
const startUpload = (index) => {
const upload = uploads[index];
if (!upload || upload.status !== 'queued') return;
setUploads(prev => prev.map((u, i) =>
i === index ? { ...u, status: 'uploading' } : u
));
// Simulate upload with progress
const interval = setInterval(() => {
setUploads(prev => {
const current = prev[index];
if (!current || current.status !== 'uploading') {
clearInterval(interval);
return prev;
}
const newProgress = Math.min(current.progress + Math.random() * 20, 100);
if (newProgress >= 100) {
clearInterval(interval);
// Start next upload in queue
const nextIndex = prev.findIndex(u => u.status === 'queued');
if (nextIndex !== -1) {
setTimeout(() => startUpload(nextIndex), 100);
}
return prev.map((u, i) =>
i === index ? { ...u, progress: 100, status: 'complete' } : u
);
}
return prev.map((u, i) =>
i === index ? { ...u, progress: newProgress } : u
);
});
}, 500);
};
const handleCancel = (id) => {
setUploads(prev => prev.map(u =>
u.id === id && u.status === 'uploading'
? { ...u, status: 'cancelled' }
: u
));
};
const handleRetry = (id) => {
const index = uploads.findIndex(u => u.id === id);
if (index !== -1) {
setUploads(prev => prev.map((u, i) =>
i === index ? { ...u, progress: 0, status: 'queued', error: null } : u
));
startUpload(index);
}
};
const handleRemove = (id) => {
setUploads(prev => prev.filter(u => u.id !== id));
};
// Calculate overall progress
const totalProgress = uploads.reduce((sum, u) => sum + u.progress, 0) / uploads.length;
const completedCount = uploads.filter(u => u.status === 'complete').length;
const errorCount = uploads.filter(u => u.status === 'error').length;
return (
<div className="multi-upload-container">
{/* Summary header */}
<div className="upload-summary">
<h3>Uploading {uploads.length} files</h3>
<div className="summary-stats">
<span>{completedCount} complete</span>
{errorCount > 0 && <span className="error-count">{errorCount} failed</span>}
</div>
<div className="overall-progress">
<div className="progress-track">
<div
className="progress-bar"
style={{ width: `${totalProgress}%` }}
/>
</div>
<span className="progress-label">{Math.round(totalProgress)}%</span>
</div>
</div>
{/* Upload list */}
<div className="upload-list">
{uploads.map(upload => (
<UploadItem
key={upload.id}
upload={upload}
onCancel={() => handleCancel(upload.id)}
onRetry={() => handleRetry(upload.id)}
onRemove={() => handleRemove(upload.id)}
/>
))}
</div>
{/* Actions */}
<div className="upload-queue-actions">
<button className="btn-secondary">
Pause All
</button>
<button className="btn-primary">
Add More Files
</button>
</div>
</div>
);
}
// Individual Upload Item Component
function UploadItem({ upload, onCancel, onRetry, onRemove }) {
const getStatusIcon = () => {
switch (upload.status) {
case 'queued':
return '⏳';
case 'uploading':
return '↑';
case 'complete':
return '✓';
case 'error':
return '✕';
case 'cancelled':
return '⊘';
default:
return '';
}
};
return (
<div className={`upload-item upload-${upload.status}`}>
<div className="upload-item-icon">
{getStatusIcon()}
</div>
<div className="upload-item-content">
<div className="upload-item-name">{upload.file.name}</div>
<div className="upload-item-progress">
<div className="progress-track-small">
<div
className="progress-bar-small"
style={{ width: `${upload.progress}%` }}
/>
</div>
<span className="progress-text-small">
{upload.status === 'uploading' && `${Math.round(upload.progress)}%`}
{upload.status === 'complete' && 'Complete'}
{upload.status === 'error' && 'Failed'}
{upload.status === 'queued' && 'Waiting...'}
</span>
</div>
</div>
<div className="upload-item-actions">
{upload.status === 'uploading' && (
<button onClick={onCancel} className="btn-icon-small">✕</button>
)}
{upload.status === 'error' && (
<button onClick={onRetry} className="btn-icon-small">↻</button>
)}
{(upload.status === 'complete' || upload.status === 'error') && (
<button onClick={onRemove} className="btn-icon-small">🗑</button>
)}
</div>
</div>
);
}
// Drag and Drop Upload with Progress
export function DragDropUpload() {
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState([]);
const dropZoneRef = useRef(null);
const handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.target === dropZoneRef.current) {
setIsDragging(false);
}
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
setFiles(prev => [...prev, ...droppedFiles]);
};
return (
<div className="drag-drop-container">
<div
ref={dropZoneRef}
className={`drop-zone ${isDragging ? 'dragging' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="drop-zone-content">
<div className="drop-icon">📁</div>
<p>Drag and drop files here</p>
<p className="drop-hint">or</p>
<label className="file-input-label">
<input
type="file"
multiple
onChange={(e) => setFiles(prev => [...prev, ...Array.from(e.target.files)])}
className="file-input-hidden"
/>
Browse Files
</label>
</div>
</div>
{files.length > 0 && (
<MultiFileUploadQueue
files={files}
onAllComplete={() => console.log('All uploads complete')}
/>
)}
</div>
);
}
// Helper function for file icons
function getFileIcon(mimeType) {
if (!mimeType) return '📄';
if (mimeType.startsWith('image/')) return '🖼️';
if (mimeType.startsWith('video/')) return '🎬';
if (mimeType.startsWith('audio/')) return '🎵';
if (mimeType.includes('pdf')) return '📑';
if (mimeType.includes('zip') || mimeType.includes('compressed')) return '🗜️';
if (mimeType.includes('text')) return '📝';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊';
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return '📈';
return '📄';
}
// Styles
const styles = `
.upload-progress-container {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.upload-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.file-icon {
font-size: 32px;
}
.file-info {
flex: 1;
}
.file-name {
font-weight: 500;
margin-bottom: 4px;
}
.file-meta {
font-size: 14px;
color: #6b7280;
}
.separator {
margin: 0 8px;
}
.progress-container {
display: flex;
align-items: center;
gap: 12px;
}
.progress-track {
flex: 1;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}
.progress-bar.progress-complete {
background: #10b981;
}
.progress-bar.progress-error {
background: #ef4444;
}
.progress-label {
font-size: 14px;
font-weight: 500;
min-width: 40px;
}
.upload-error {
color: #ef4444;
font-size: 14px;
margin-top: 8px;
}
.upload-success {
color: #10b981;
font-size: 14px;
margin-top: 8px;
}
/* Multi-upload styles */
.multi-upload-container {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 24px;
}
.upload-summary {
margin-bottom: 24px;
}
.summary-stats {
display: flex;
gap: 16px;
margin: 8px 0;
font-size: 14px;
color: #6b7280;
}
.error-count {
color: #ef4444;
}
.overall-progress {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.upload-list {
space-y: 8px;
max-height: 400px;
overflow-y: auto;
}
.upload-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #f9fafb;
border-radius: 6px;
margin-bottom: 8px;
}
.upload-item-icon {
font-size: 20px;
}
.upload-item-content {
flex: 1;
}
.upload-item-name {
font-size: 14px;
margin-bottom: 4px;
}
.progress-track-small {
height: 4px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
margin-top: 4px;
}
.progress-bar-small {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}
.progress-text-small {
font-size: 12px;
color: #6b7280;
}
/* Drag and drop styles */
.drop-zone {
border: 2px dashed #cbd5e1;
border-radius: 8px;
padding: 48px;
text-align: center;
transition: all 0.3s ease;
}
.drop-zone.dragging {
border-color: #3b82f6;
background: #eff6ff;
}
.drop-zone-content {
pointer-events: none;
}
.drop-icon {
font-size: 48px;
margin-bottom: 16px;
}
.drop-hint {
color: #6b7280;
margin: 8px 0;
}
.file-input-label {
display: inline-block;
padding: 8px 16px;
background: #3b82f6;
color: white;
border-radius: 6px;
cursor: pointer;
pointer-events: auto;
}
.file-input-hidden {
display: none;
}
`;import React from 'react';
import { Toaster, toast } from 'sonner';
/**
* Success Toast Implementation Example
*
* Demonstrates various success notification patterns using Sonner
*/
// Basic App Setup with Toaster
export function App() {
return (
<>
{/* Configure global toaster */}
<Toaster
position="bottom-right"
toastOptions={{
duration: 4000,
style: {
background: 'var(--toast-bg)',
color: 'var(--toast-text)',
border: '1px solid var(--toast-border)'
}
}}
/>
{/* Your application components */}
<SuccessExamples />
</>
);
}
// Success Toast Examples
export function SuccessExamples() {
// Simple success notification
const handleSimpleSuccess = () => {
toast.success('Changes saved successfully');
};
// Success with description
const handleDetailedSuccess = () => {
toast.success('Profile Updated', {
description: 'Your profile information has been saved',
duration: 5000
});
};
// Success with custom icon
const handleCustomIconSuccess = () => {
toast('Upload Complete', {
icon: '📁',
description: '5 files uploaded successfully'
});
};
// Success with action button
const handleSuccessWithAction = () => {
toast.success('Item created successfully', {
action: {
label: 'View',
onClick: () => console.log('Viewing item')
},
duration: 10000 // Longer duration for action
});
};
// Success with undo action
const handleSuccessWithUndo = () => {
let deletedItem = { id: 1, name: 'Important Document' };
toast.success('Item deleted', {
description: deletedItem.name,
action: {
label: 'Undo',
onClick: () => {
// Restore the item
console.log('Restoring item:', deletedItem);
toast.success('Item restored');
}
},
duration: 10000
});
};
// Promise-based success (async operation)
const handleAsyncOperation = async () => {
const saveData = () => new Promise((resolve) => {
setTimeout(() => resolve({ id: 1, name: 'Document' }), 2000);
});
toast.promise(saveData(), {
loading: 'Saving document...',
success: (data) => ({
title: 'Document saved',
description: `${data.name} has been saved successfully`
}),
error: 'Failed to save document'
});
};
// Batch success notifications
const handleBatchSuccess = () => {
const items = ['File1.pdf', 'File2.doc', 'File3.png'];
items.forEach((item, index) => {
setTimeout(() => {
toast.success(`${item} uploaded`, {
id: `upload-${index}` // Prevent duplicates
});
}, index * 200); // Stagger notifications
});
};
// Custom styled success
const handleCustomStyledSuccess = () => {
toast.custom((t) => (
<div
className={`
${t.visible ? 'animate-enter' : 'animate-leave'}
max-w-md w-full bg-white shadow-lg rounded-lg pointer-events-auto
flex ring-1 ring-black ring-opacity-5
`}
>
<div className="flex-1 w-0 p-4">
<div className="flex items-start">
<div className="flex-shrink-0 pt-0.5">
<div className="h-10 w-10 rounded-full bg-green-500 flex items-center justify-center">
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<div className="ml-3 flex-1">
<p className="text-sm font-medium text-gray-900">
Successfully saved!
</p>
<p className="mt-1 text-sm text-gray-500">
Your changes have been saved to the cloud
</p>
</div>
</div>
</div>
<div className="flex border-l border-gray-200">
<button
onClick={() => toast.dismiss(t.id)}
className="w-full border border-transparent rounded-none rounded-r-lg p-4 flex items-center justify-center text-sm font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
Close
</button>
</div>
</div>
));
};
// Success with progress update
const handleProgressSuccess = () => {
const toastId = toast.loading('Processing files...');
// Simulate progress updates
setTimeout(() => {
toast.loading('Processing files... 25%', { id: toastId });
}, 1000);
setTimeout(() => {
toast.loading('Processing files... 75%', { id: toastId });
}, 2000);
setTimeout(() => {
toast.success('All files processed successfully!', { id: toastId });
}, 3000);
};
return (
<div className="p-8 space-y-4">
<h2 className="text-2xl font-bold mb-6">Success Toast Examples</h2>
<div className="grid grid-cols-2 gap-4">
<button
onClick={handleSimpleSuccess}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Simple Success
</button>
<button
onClick={handleDetailedSuccess}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Detailed Success
</button>
<button
onClick={handleCustomIconSuccess}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Custom Icon Success
</button>
<button
onClick={handleSuccessWithAction}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Success with Action
</button>
<button
onClick={handleSuccessWithUndo}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Success with Undo
</button>
<button
onClick={handleAsyncOperation}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Async Operation
</button>
<button
onClick={handleBatchSuccess}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Batch Success
</button>
<button
onClick={handleCustomStyledSuccess}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Custom Styled
</button>
<button
onClick={handleProgressSuccess}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Progress Success
</button>
</div>
</div>
);
}
// Alternative implementation with react-hot-toast
export function ReactHotToastExample() {
import toast, { Toaster } from 'react-hot-toast';
const handleSuccess = () => {
toast.success('Operation successful!', {
duration: 4000,
position: 'bottom-right',
style: {
background: '#10b981',
color: '#fff',
},
icon: '✅',
});
};
const handlePromise = () => {
const myPromise = fetch('/api/data');
toast.promise(
myPromise,
{
loading: 'Loading...',
success: 'Got the data!',
error: 'Error when fetching',
}
);
};
return (
<>
<Toaster />
<button onClick={handleSuccess}>Show Success</button>
<button onClick={handlePromise}>Promise Toast</button>
</>
);
}
// Success toast with accessibility
export function AccessibleSuccessToast({ message, onAction }) {
const showAccessibleToast = () => {
// Create custom toast with proper ARIA attributes
toast.custom((t) => (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="toast-success"
>
<div className="toast-content">
<span className="sr-only">Success:</span>
<span className="toast-icon" aria-hidden="true">✓</span>
<span className="toast-message">{message}</span>
</div>
{onAction && (
<button
onClick={() => {
onAction();
toast.dismiss(t.id);
}}
className="toast-action"
aria-label="View details"
>
View
</button>
)}
</div>
));
};
return (
<button onClick={showAccessibleToast}>
Show Accessible Success
</button>
);
}
// Styled components for success toasts
const styles = `
.toast-success {
background: var(--color-success-bg, #10b981);
color: var(--color-success-text, white);
padding: var(--spacing-md, 16px);
border-radius: var(--radius-md, 8px);
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
gap: 12px;
min-width: 300px;
max-width: 500px;
}
.toast-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
}
.toast-message {
flex: 1;
}
.toast-action {
padding: 4px 12px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
color: white;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.toast-action:hover {
background: rgba(255, 255, 255, 0.3);
}
@keyframes animate-enter {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes animate-leave {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100%);
}
}
.animate-enter {
animation: animate-enter 0.3s ease-out;
}
.animate-leave {
animation: animate-leave 0.2s ease-in forwards;
}
`;skill: "providing-feedback"
version: "1.0"
domain: "frontend"
base_outputs:
- path: "components/Toast.tsx"
must_contain: ["toast", "notification", "Toaster"]
description: "Toast notification component using Sonner or react-hot-toast with positioning and timing"
- path: "components/Modal.tsx"
must_contain: ["Dialog", "modal", "aria-modal"]
description: "Modal dialog component with focus management and accessibility using Radix UI or Headless UI"
- path: "components/Alert.tsx"
must_contain: ["alert", "role=\"alert\"", "aria-live"]
description: "Alert banner component for important non-blocking messages with ARIA live regions"
conditional_outputs:
maturity:
starter:
- path: "components/Toast.tsx"
must_contain: ["toast.success", "toast.error"]
description: "Basic toast notifications with success/error states"
- path: "components/ConfirmDialog.tsx"
must_contain: ["Dialog", "confirm", "cancel"]
description: "Simple confirmation dialog for delete actions"
- path: "components/LoadingSpinner.tsx"
must_contain: ["spinner", "loading", "aria-busy"]
description: "Basic loading spinner with accessibility"
intermediate:
- path: "components/Toast.tsx"
must_contain: ["toast.promise", "action", "undo"]
description: "Advanced toast with promise handling, actions, and undo functionality"
- path: "components/Modal.tsx"
must_contain: ["focus trap", "previousFocus", "ESC"]
description: "Modal with complete focus management and keyboard interactions"
- path: "components/ProgressBar.tsx"
must_contain: ["progress", "aria-valuenow", "percentage"]
description: "Determinate progress bar for operations 5-30 seconds"
- path: "components/EmptyState.tsx"
must_contain: ["illustration", "CTA", "empty"]
description: "Empty state component with illustration and call-to-action"
- path: "components/InlineMessage.tsx"
must_contain: ["inline", "contextual", "help"]
description: "Inline contextual messages for form validation and help"
advanced:
- path: "components/ToastManager.tsx"
must_contain: ["stack", "limit", "position", "queue"]
description: "Toast management system with stacking, limits, and queuing"
- path: "components/Modal.tsx"
must_contain: ["portal", "backdrop", "nested modals", "focus scope"]
description: "Advanced modal with portal rendering, backdrop control, and nested modal support"
- path: "components/ProgressIndicator.tsx"
must_contain: ["determinate", "indeterminate", "time estimate", "cancel"]
description: "Intelligent progress indicator with auto-selection based on operation duration"
- path: "components/SkeletonScreen.tsx"
must_contain: ["skeleton", "shimmer", "placeholder"]
description: "Skeleton screens for content loading states"
- path: "hooks/useToast.ts"
must_contain: ["hook", "context", "provider"]
description: "Custom toast hook with context for app-wide notifications"
- path: "utils/messageFormatter.ts"
must_contain: ["format", "template", "error catalog"]
description: "Message formatting utilities with templates and error catalogs"
frontend_framework:
react:
- path: "components/Toast.tsx"
must_contain: ["import { toast } from 'sonner'", "Toaster"]
description: "React toast implementation using Sonner"
- path: "components/Modal.tsx"
must_contain: ["import * as Dialog from '@radix-ui/react-dialog'", "Dialog.Root"]
description: "React modal using Radix UI Dialog"
- path: "hooks/useModal.ts"
must_contain: ["useState", "useRef", "useEffect"]
description: "React hooks for modal state and focus management"
vue:
- path: "components/Toast.vue"
must_contain: ["<template>", "ref", "provide/inject"]
description: "Vue toast component with composition API"
- path: "components/Modal.vue"
must_contain: ["<Teleport>", "onMounted", "onUnmounted"]
description: "Vue modal using Teleport and lifecycle hooks"
- path: "composables/useToast.ts"
must_contain: ["ref", "computed", "provide"]
description: "Vue composable for toast notifications"
svelte:
- path: "components/Toast.svelte"
must_contain: ["<script>", "writable", "onMount"]
description: "Svelte toast component with stores"
- path: "components/Modal.svelte"
must_contain: ["transition:", "bind:this", "onDestroy"]
description: "Svelte modal with transitions and actions"
styling:
tailwind:
- path: "components/Toast.tsx"
must_contain: ["className", "bg-", "rounded-", "shadow-"]
description: "Toast styled with Tailwind utility classes"
- path: "components/Modal.tsx"
must_contain: ["fixed inset-0", "bg-opacity-", "transform"]
description: "Modal with Tailwind backdrop and positioning"
css_modules:
- path: "components/Toast.module.css"
must_contain: [".toast", "animation", "@keyframes"]
description: "CSS modules for toast animations and styles"
- path: "components/Modal.module.css"
must_contain: [".overlay", ".content", "@media (prefers-reduced-motion)"]
description: "CSS modules for modal with reduced motion support"
styled_components:
- path: "components/Toast.styled.ts"
must_contain: ["styled", "keyframes", "css"]
description: "Styled-components for toast with theme tokens"
- path: "components/Modal.styled.ts"
must_contain: ["styled.div", "createPortal", "theme"]
description: "Styled-components for modal with theme integration"
scaffolding:
- path: "components/feedback/index.ts"
reason: "Barrel export for all feedback components"
- path: "types/feedback.ts"
reason: "TypeScript types for toast options, modal props, and alert variants"
- path: "constants/timing.ts"
reason: "Timing constants for auto-dismiss durations and thresholds"
- path: "utils/aria.ts"
reason: "ARIA utilities for live regions and focus management"
- path: "contexts/ToastContext.tsx"
reason: "React context for app-wide toast management (intermediate+)"
- path: "assets/message-templates.json"
reason: "Reusable message templates for common scenarios"
metadata:
primary_blueprints: ["dashboard", "frontend"]
contributes_to:
- "Toast notifications"
- "Alert components"
- "Loading states"
- "Modal dialogs"
- "Progress indicators"
- "Empty states"
- "Form validation feedback"
- "Confirmation dialogs"
- "Success/error messaging"
- "Inline help and tooltips"
libraries:
toast:
- name: "sonner"
reason: "Modern React 18+ with best-in-class accessibility"
maturity: ["starter", "intermediate", "advanced"]
- name: "react-hot-toast"
reason: "Minimal bundle size (<5KB) for simple use cases"
maturity: ["starter", "intermediate"]
- name: "react-toastify"
reason: "RTL support and mobile optimization"
maturity: ["intermediate", "advanced"]
modal:
- name: "@radix-ui/react-dialog"
reason: "Headless, accessible, design system friendly"
maturity: ["intermediate", "advanced"]
- name: "@headlessui/react"
reason: "Tailwind integration with transitions"
maturity: ["intermediate", "advanced"]
progress:
- name: "nprogress"
reason: "Page-level loading bar"
maturity: ["starter", "intermediate"]
design_tokens_used:
- "color.toast.bg"
- "color.toast.text"
- "color.toast.border"
- "color.alert.success"
- "color.alert.error"
- "color.alert.warning"
- "color.alert.info"
- "color.modal.overlay"
- "color.modal.bg"
- "spacing.toast.padding"
- "spacing.modal.padding"
- "shadow.toast"
- "shadow.modal"
- "radius.toast"
- "radius.modal"
- "motion.toast.enter-duration"
- "motion.toast.exit-duration"
- "motion.modal.enter-duration"
- "motion.modal.exit-duration"
- "z-index.toast"
- "z-index.modal"
accessibility_features:
- "ARIA live regions (polite/assertive)"
- "Focus trap in modals"
- "Focus restoration after modal close"
- "ESC key to close modals"
- "Keyboard navigation"
- "Screen reader announcements"
- "Reduced motion support"
- "Proper ARIA labels and descriptions"
timing_guidelines:
success: "3-4 seconds"
info: "4-5 seconds"
warning: "5-7 seconds"
error: "7-10 seconds or manual dismiss"
with_action: "10+ seconds or no auto-dismiss"
no_indicator: "<100ms"
spinner: "100ms-5s"
progress_bar: "5s-30s"
progress_with_cancel: ">30s"
cross_skill_integration:
- skill: "building-forms"
integration: "Form validation feedback and success messages"
- skill: "visualizing-data"
integration: "Chart loading states and error messages"
- skill: "building-tables"
integration: "Bulk operation confirmations and action feedback"
- skill: "implementing-ai-chat"
integration: "Streaming indicators and rate limit warnings"
- skill: "building-dashboards"
integration: "Widget loading states and system status alerts"
- skill: "implementing-search-filter"
integration: "Zero results empty states and search progress"
- skill: "handling-media"
integration: "Upload progress and processing status"
- skill: "theming-components"
integration: "All visual styling via design token system"
Accessibility Patterns for Feedback Components
Table of Contents
- Overview
- ARIA Live Regions
- Understanding Live Regions
- Implementation Patterns
- Custom Announcer Hook
- Focus Management
- Modal Focus Trap
- React Hook for Focus Trap
- Focus Restoration
- Keyboard Navigation
- Keyboard Shortcut Handler
- Roving TabIndex
- Screen Reader Support
- Visually Hidden Content
- Screen Reader Announcements
- Descriptive Labels
- ARIA Patterns
- Alert Dialog
- Status Messages
- Loading States
- Color and Contrast
- High Contrast Support
- Color Independence
- Motion and Animation
- Reduced Motion Support
- Reduced Motion Hook
- Testing Accessibility
- Accessibility Testing Checklist
- Automated Testing
- Best Practices
Overview
Ensuring feedback components are accessible means they can be perceived, understood, and interacted with by all users, including those using assistive technologies.
ARIA Live Regions
Understanding Live Regions
<!-- aria-live values -->
<div aria-live="off"> <!-- Not announced (default) -->
<div aria-live="polite"> <!-- Announced when user pauses -->
<div aria-live="assertive"> <!-- Announced immediately -->Implementation Patterns
Toast Notifications
function AccessibleToast({ message, type = 'info' }) {
const ariaLive = type === 'error' ? 'assertive' : 'polite';
return (
<div
role={type === 'error' ? 'alert' : 'status'}
aria-live={ariaLive}
aria-atomic="true"
className={`toast toast-${type}`}
>
<span className="toast-icon" aria-hidden="true">
{getIcon(type)}
</span>
<span className="toast-message">{message}</span>
</div>
);
}Progress Updates
function AccessibleProgress({ value, max = 100 }) {
const percentage = Math.round((value / max) * 100);
return (
<>
{/* Visual progress bar */}
<div
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={max}
aria-label="Upload progress"
>
<div className="progress-bar" style={{ width: `${percentage}%` }} />
</div>
{/* Live region for updates */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{percentage}% complete
</div>
</>
);
}Dynamic Content Updates
function DynamicContentRegion({ content, priority = 'polite' }) {
return (
<div
aria-live={priority}
aria-atomic="true"
aria-relevant="additions removals text"
>
{content}
</div>
);
}Custom Announcer Hook
function useAnnouncer() {
const announcerRef = useRef<HTMLDivElement>();
useEffect(() => {
// Create announcer element
const announcer = document.createElement('div');
announcer.setAttribute('aria-live', 'polite');
announcer.setAttribute('aria-atomic', 'true');
announcer.className = 'sr-only';
document.body.appendChild(announcer);
announcerRef.current = announcer;
return () => {
document.body.removeChild(announcer);
};
}, []);
const announce = useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
if (!announcerRef.current) return;
// Update aria-live if needed
announcerRef.current.setAttribute('aria-live', priority);
// Clear and set message
announcerRef.current.textContent = '';
setTimeout(() => {
if (announcerRef.current) {
announcerRef.current.textContent = message;
}
}, 100);
}, []);
return announce;
}
// Usage
function MyComponent() {
const announce = useAnnouncer();
const handleSave = async () => {
try {
await saveData();
announce('Changes saved successfully');
} catch (error) {
announce('Failed to save changes', 'assertive');
}
};
}Focus Management
Modal Focus Trap
class FocusManager {
private previousFocus: HTMLElement | null = null;
private container: HTMLElement;
private focusableSelectors = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
];
constructor(container: HTMLElement) {
this.container = container;
}
trap() {
// Save current focus
this.previousFocus = document.activeElement as HTMLElement;
// Get focusable elements
const focusableElements = this.getFocusableElements();
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Focus first element
firstElement.focus();
// Handle Tab navigation
this.handleTabKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
this.container.addEventListener('keydown', this.handleTabKey);
}
release() {
// Remove event listener
if (this.handleTabKey) {
this.container.removeEventListener('keydown', this.handleTabKey);
}
// Restore focus
if (this.previousFocus && this.previousFocus.focus) {
this.previousFocus.focus();
}
}
private getFocusableElements(): HTMLElement[] {
const selector = this.focusableSelectors.join(',');
return Array.from(this.container.querySelectorAll(selector));
}
private handleTabKey: ((e: KeyboardEvent) => void) | null = null;
}React Hook for Focus Trap
function useFocusTrap(isActive: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const focusManagerRef = useRef<FocusManager>();
useEffect(() => {
if (!isActive || !containerRef.current) return;
const manager = new FocusManager(containerRef.current);
manager.trap();
focusManagerRef.current = manager;
return () => {
manager.release();
};
}, [isActive]);
return containerRef;
}
// Usage
function Modal({ isOpen, onClose }) {
const modalRef = useFocusTrap(isOpen);
return (
<div ref={modalRef} className="modal">
{/* Modal content */}
</div>
);
}Focus Restoration
function useFocusRestoration() {
const lastFocusRef = useRef<HTMLElement | null>(null);
const saveFocus = useCallback(() => {
lastFocusRef.current = document.activeElement as HTMLElement;
}, []);
const restoreFocus = useCallback(() => {
if (lastFocusRef.current && typeof lastFocusRef.current.focus === 'function') {
lastFocusRef.current.focus();
}
}, []);
return { saveFocus, restoreFocus };
}Keyboard Navigation
Keyboard Shortcut Handler
interface KeyboardShortcut {
key: string;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
handler: () => void;
}
function useKeyboardShortcuts(shortcuts: KeyboardShortcut[], isActive = true) {
useEffect(() => {
if (!isActive) return;
const handleKeyDown = (e: KeyboardEvent) => {
shortcuts.forEach(shortcut => {
const matchesKey = e.key === shortcut.key;
const matchesCtrl = shortcut.ctrl ? e.ctrlKey || e.metaKey : true;
const matchesShift = shortcut.shift ? e.shiftKey : true;
const matchesAlt = shortcut.alt ? e.altKey : true;
if (matchesKey && matchesCtrl && matchesShift && matchesAlt) {
e.preventDefault();
shortcut.handler();
}
});
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [shortcuts, isActive]);
}
// Usage
function NotificationCenter() {
const [isOpen, setIsOpen] = useState(false);
useKeyboardShortcuts([
{ key: 'Escape', handler: () => setIsOpen(false) },
{ key: 'n', ctrl: true, handler: () => setIsOpen(true) },
{ key: '/', handler: () => focusSearch() }
], isOpen);
return (
// Component JSX
);
}Roving TabIndex
function RovingTabIndex({ items }) {
const [focusedIndex, setFocusedIndex] = useState(0);
const handleKeyDown = (e: KeyboardEvent, index: number) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusedIndex((index + 1) % items.length);
break;
case 'ArrowUp':
e.preventDefault();
setFocusedIndex((index - 1 + items.length) % items.length);
break;
case 'Home':
e.preventDefault();
setFocusedIndex(0);
break;
case 'End':
e.preventDefault();
setFocusedIndex(items.length - 1);
break;
}
};
return (
<div role="list">
{items.map((item, index) => (
<div
key={item.id}
role="listitem"
tabIndex={index === focusedIndex ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, index)}
ref={el => {
if (index === focusedIndex && el) {
el.focus();
}
}}
>
{item.content}
</div>
))}
</div>
);
}Screen Reader Support
Visually Hidden Content
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Visible on focus (for skip links) */
.sr-only-focusable:focus {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: inherit;
overflow: visible;
clip: auto;
white-space: normal;
}Screen Reader Announcements
function ScreenReaderAnnouncement({ message, priority = 'polite' }) {
return (
<div
role={priority === 'assertive' ? 'alert' : 'status'}
aria-live={priority}
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
}Descriptive Labels
function AccessibleButton({ onClick, label, description, icon }) {
const buttonId = useId();
const descriptionId = `${buttonId}-description`;
return (
<>
<button
id={buttonId}
onClick={onClick}
aria-label={label}
aria-describedby={description ? descriptionId : undefined}
>
<span aria-hidden="true">{icon}</span>
<span className="sr-only">{label}</span>
</button>
{description && (
<span id={descriptionId} className="sr-only">
{description}
</span>
)}
</>
);
}ARIA Patterns
Alert Dialog
function AlertDialog({ isOpen, title, message, onConfirm, onCancel }) {
return (
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="alert-title"
aria-describedby="alert-message"
>
<h2 id="alert-title">{title}</h2>
<p id="alert-message">{message}</p>
<div className="alert-actions">
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm} autoFocus>Confirm</button>
</div>
</div>
);
}Status Messages
function StatusMessage({ type, message }) {
const role = type === 'error' ? 'alert' : 'status';
const ariaLive = type === 'error' ? 'assertive' : 'polite';
return (
<div
role={role}
aria-live={ariaLive}
aria-atomic="true"
className={`status status-${type}`}
>
<span className="status-icon" aria-hidden="true">
{getStatusIcon(type)}
</span>
<span className="status-message">{message}</span>
</div>
);
}Loading States
function LoadingState({ isLoading, loadingText = 'Loading...' }) {
return (
<div
aria-busy={isLoading}
aria-live="polite"
aria-relevant="additions removals"
>
{isLoading ? (
<div role="status">
<span className="spinner" aria-hidden="true" />
<span>{loadingText}</span>
</div>
) : (
<div>Content loaded</div>
)}
</div>
);
}Color and Contrast
High Contrast Support
/* Windows High Contrast Mode */
@media (prefers-contrast: high) {
.toast {
outline: 2px solid currentColor;
}
.modal-overlay {
background: Canvas;
opacity: 0.9;
}
.progress-bar {
background: Highlight;
}
}
/* Forced colors mode */
@media (forced-colors: active) {
.alert {
border: 1px solid;
}
.btn-primary {
border: 2px solid;
}
}Color Independence
function ColorIndependentAlert({ type, message }) {
const icons = {
success: '✓',
error: '✕',
warning: '⚠',
info: 'ℹ'
};
return (
<div className={`alert alert-${type}`}>
{/* Don't rely on color alone */}
<span className="alert-icon" aria-label={type}>
{icons[type]}
</span>
<span className="alert-type-text sr-only">{type}:</span>
<span className="alert-message">{message}</span>
</div>
);
}Motion and Animation
Reduced Motion Support
/* Respect user preferences */
@media (prefers-reduced-motion: reduce) {
.toast,
.modal,
.progress-bar {
animation: none !important;
transition: opacity 0.01ms !important;
}
.spinner {
animation: none;
border-top-color: transparent;
border-right-color: var(--spinner-color);
}
}
/* JavaScript detection */
.reduced-motion .animated-element {
animation: none;
}Reduced Motion Hook
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mediaQuery.matches);
const listener = (e: MediaQueryListEvent) => {
setPrefersReducedMotion(e.matches);
};
mediaQuery.addEventListener('change', listener);
return () => mediaQuery.removeEventListener('change', listener);
}, []);
return prefersReducedMotion;
}
// Usage
function AnimatedToast({ message }) {
const prefersReducedMotion = usePrefersReducedMotion();
return (
<div
className={`toast ${prefersReducedMotion ? 'no-animation' : 'animated'}`}
>
{message}
</div>
);
}Testing Accessibility
Accessibility Testing Checklist
interface AccessibilityTests {
keyboard: {
tabNavigation: boolean;
escapeKey: boolean;
enterKey: boolean;
arrowKeys: boolean;
shortcuts: boolean;
};
screenReader: {
announcements: boolean;
labels: boolean;
descriptions: boolean;
roleAttributes: boolean;
liveRegions: boolean;
};
visual: {
colorContrast: boolean;
focusIndicators: boolean;
textSize: boolean;
highContrast: boolean;
};
interaction: {
focusTrap: boolean;
focusRestoration: boolean;
dismissible: boolean;
timeouts: boolean;
};
}Automated Testing
// Using jest-axe for automated accessibility testing
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Toast Accessibility', () => {
it('should have no accessibility violations', async () => {
const { container } = render(
<Toast message="Test message" type="success" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should announce to screen readers', () => {
const { getByRole } = render(
<Toast message="Success" type="success" />
);
expect(getByRole('status')).toHaveAttribute('aria-live', 'polite');
});
});Best Practices
1. Always use semantic HTML: Prefer native elements over ARIA 2. Test with real assistive technology: Screen readers, keyboard only 3. Provide multiple cues: Don't rely on color or sound alone 4. Respect user preferences: Reduced motion, high contrast 5. Keep focus visible: Never remove focus indicators 6. Announce changes: Use live regions for dynamic content 7. Manage focus properly: Trap in modals, restore on close 8. Provide escape routes: Always allow keyboard dismissal 9. Test at different zoom levels: Ensure 200% zoom works 10. Document accessibility features: Help users understand available features
Alert Banner Implementation Patterns
Table of Contents
- Overview
- Alert Types & Severity
- Information Alert
- Success Alert
- Warning Alert
- Error Alert
- Positioning Strategies
- Page-Level Alert (Top)
- Section-Level Alert
- Inline Alert (Within Content)
- Alert Anatomy
- Standard Alert Structure
- Expandable Alert
- Animation Patterns
- Slide Down Entry
- Fade Out Dismissal
- Multi-Alert Management
- Alert Stack
- Priority Queue
- Responsive Design
- Mobile Adaptation
- Compact Mode
- Accessibility
- ARIA Attributes
- Keyboard Navigation
- Screen Reader Announcements
- Implementation Examples
- With Radix UI Alert Dialog
- Custom Alert System
- Best Practices
Overview
Alert banners are persistent notifications that appear at the top of a page or section to communicate important information that requires user awareness but not immediate action.
Alert Types & Severity
Information Alert
interface InfoAlert {
type: 'info';
icon: InfoIcon;
title?: string;
message: string;
dismissible: true;
actions?: Action[];
style: {
background: 'var(--alert-info-bg)',
borderColor: 'var(--alert-info-border)',
iconColor: 'var(--alert-info-icon)'
};
}Use cases:
- System announcements
- Feature updates
- Helpful tips
- Non-critical information
Success Alert
interface SuccessAlert {
type: 'success';
icon: CheckCircleIcon;
title?: string;
message: string;
dismissible: true;
autoDissmiss?: number; // Optional auto-dismiss after X seconds
style: {
background: 'var(--alert-success-bg)',
borderColor: 'var(--alert-success-border)',
iconColor: 'var(--alert-success-icon)'
};
}Use cases:
- Operation completed successfully
- Form submitted
- Settings saved
- Process completed
Warning Alert
interface WarningAlert {
type: 'warning';
icon: ExclamationTriangleIcon;
title?: string;
message: string;
dismissible: true;
actions?: Action[];
style: {
background: 'var(--alert-warning-bg)',
borderColor: 'var(--alert-warning-border)',
iconColor: 'var(--alert-warning-icon)'
};
}Use cases:
- Approaching limits (storage, usage)
- Deprecation notices
- Potential issues
- Required actions soon
Error Alert
interface ErrorAlert {
type: 'error';
icon: XCircleIcon;
title?: string;
message: string;
dismissible: false; // Often not dismissible until resolved
actions?: Action[];
retry?: () => void;
style: {
background: 'var(--alert-error-bg)',
borderColor: 'var(--alert-error-border)',
iconColor: 'var(--alert-error-icon)'
};
}Use cases:
- System errors
- Connection issues
- Critical failures
- Required immediate attention
Positioning Strategies
Page-Level Alert (Top)
.alert-container {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: var(--z-index-alert, 1000);
}
.alert {
width: 100%;
padding: 12px 24px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Push down page content */
body.has-alert {
padding-top: 60px; /* Alert height */
}Section-Level Alert
.section-alert {
margin-bottom: 16px;
border-radius: var(--radius-md);
border: 1px solid;
}
.card .section-alert {
margin: -16px -16px 16px -16px; /* Negative margin to align with card edges */
border-radius: var(--radius-md) var(--radius-md) 0 0;
}Inline Alert (Within Content)
.inline-alert {
margin: 16px 0;
padding: 12px 16px;
border-left: 4px solid;
background: var(--alert-bg);
}Alert Anatomy
Standard Alert Structure
function Alert({ type, title, message, dismissible, actions }) {
return (
<div className={`alert alert-${type}`} role="alert">
{/* Icon */}
<div className="alert-icon">
{getIcon(type)}
</div>
{/* Content */}
<div className="alert-content">
{title && <div className="alert-title">{title}</div>}
<div className="alert-message">{message}</div>
{/* Actions */}
{actions && (
<div className="alert-actions">
{actions.map(action => (
<button
key={action.id}
onClick={action.onClick}
className={`alert-action ${action.primary ? 'primary' : ''}`}
>
{action.label}
</button>
))}
</div>
)}
</div>
{/* Dismiss button */}
{dismissible && (
<button className="alert-dismiss" aria-label="Dismiss">
<XIcon />
</button>
)}
</div>
);
}Expandable Alert
function ExpandableAlert({ title, summary, details }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="alert expandable-alert">
<div className="alert-header">
<div className="alert-icon">
<InfoIcon />
</div>
<div className="alert-content">
<div className="alert-title">{title}</div>
<div className="alert-summary">{summary}</div>
</div>
<button
className="alert-expand"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
>
{expanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
</button>
</div>
{expanded && (
<div className="alert-details">
{details}
</div>
)}
</div>
);
}Animation Patterns
Slide Down Entry
@keyframes alert-slide-down {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.alert-enter {
animation: alert-slide-down 300ms ease-out;
}Fade Out Dismissal
@keyframes alert-fade-out {
from {
opacity: 1;
height: auto;
}
to {
opacity: 0;
height: 0;
margin: 0;
padding: 0;
}
}
.alert-exit {
animation: alert-fade-out 200ms ease-in;
overflow: hidden;
}Multi-Alert Management
Alert Stack
interface AlertStackState {
alerts: Alert[];
maxVisible: number;
}
function AlertStack() {
const [alerts, setAlerts] = useState<Alert[]>([]);
const MAX_VISIBLE = 3;
const addAlert = (alert: Alert) => {
setAlerts(prev => {
const newAlerts = [...prev, alert];
// Keep only the most recent MAX_VISIBLE alerts
return newAlerts.slice(-MAX_VISIBLE);
});
};
const dismissAlert = (id: string) => {
setAlerts(prev => prev.filter(a => a.id !== id));
};
return (
<div className="alert-stack">
{alerts.map((alert, index) => (
<div
key={alert.id}
className="alert-wrapper"
style={{
transform: `translateY(${index * 4}px)`,
zIndex: alerts.length - index
}}
>
<Alert
{...alert}
onDismiss={() => dismissAlert(alert.id)}
/>
</div>
))}
</div>
);
}Priority Queue
class AlertQueue {
private queue: Alert[] = [];
add(alert: Alert) {
// Insert based on priority
const insertIndex = this.queue.findIndex(
a => this.getPriority(a.type) < this.getPriority(alert.type)
);
if (insertIndex === -1) {
this.queue.push(alert);
} else {
this.queue.splice(insertIndex, 0, alert);
}
this.render();
}
private getPriority(type: string): number {
const priorities = {
error: 4,
warning: 3,
success: 2,
info: 1
};
return priorities[type] || 0;
}
}Responsive Design
Mobile Adaptation
/* Mobile: Full width, smaller padding */
@media (max-width: 768px) {
.alert {
border-radius: 0;
padding: 8px 12px;
font-size: 14px;
}
.alert-actions {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.alert-action {
width: 100%;
padding: 8px;
}
}
/* Desktop: Contained width, more padding */
@media (min-width: 769px) {
.page-alert {
max-width: 1200px;
margin: 0 auto;
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
}Compact Mode
function CompactAlert({ message, type }) {
return (
<div className={`alert alert-compact alert-${type}`}>
<span className="alert-icon-compact">
{getCompactIcon(type)}
</span>
<span className="alert-message-compact">{message}</span>
</div>
);
}Accessibility
ARIA Attributes
<!-- Standard alert -->
<div role="alert" aria-live="polite">
<h3 id="alert-title">System Maintenance</h3>
<p aria-describedby="alert-title">
The system will be down for maintenance at 2 AM.
</p>
</div>
<!-- Critical alert -->
<div role="alert" aria-live="assertive" aria-atomic="true">
<span class="sr-only">Error:</span>
Connection lost. Please check your internet connection.
</div>Keyboard Navigation
class AccessibleAlert {
constructor(element) {
const dismissButton = element.querySelector('.alert-dismiss');
const actionButtons = element.querySelectorAll('.alert-action');
// Focus management
if (element.dataset.autoFocus === 'true') {
(actionButtons[0] || dismissButton)?.focus();
}
// Keyboard shortcuts
element.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && dismissButton) {
dismissButton.click();
}
});
}
}Screen Reader Announcements
function ScreenReaderAlert({ message, priority = 'polite' }) {
return (
<div
role="status"
aria-live={priority}
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
}Implementation Examples
With Radix UI Alert Dialog
import * as AlertDialog from '@radix-ui/react-alert-dialog';
function CriticalAlert({ title, description, onConfirm }) {
return (
<AlertDialog.Root>
<AlertDialog.Trigger asChild>
<button className="alert-trigger">Show Alert</button>
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay className="alert-overlay" />
<AlertDialog.Content className="alert-content">
<AlertDialog.Title className="alert-title">
{title}
</AlertDialog.Title>
<AlertDialog.Description className="alert-description">
{description}
</AlertDialog.Description>
<div className="alert-buttons">
<AlertDialog.Cancel asChild>
<button className="alert-cancel">Cancel</button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<button className="alert-confirm" onClick={onConfirm}>
Confirm
</button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
}Custom Alert System
const AlertContext = createContext();
export function AlertProvider({ children }) {
const [alerts, setAlerts] = useState([]);
const showAlert = useCallback((alert) => {
const id = Date.now().toString();
const newAlert = { ...alert, id };
setAlerts(prev => [...prev, newAlert]);
// Auto-dismiss if specified
if (alert.autoDissmiss) {
setTimeout(() => {
dismissAlert(id);
}, alert.autoDissmiss);
}
return id;
}, []);
const dismissAlert = useCallback((id) => {
setAlerts(prev => prev.filter(a => a.id !== id));
}, []);
return (
<AlertContext.Provider value={{ showAlert, dismissAlert }}>
{children}
<div className="alert-container">
{alerts.map(alert => (
<Alert
key={alert.id}
{...alert}
onDismiss={() => dismissAlert(alert.id)}
/>
))}
</div>
</AlertContext.Provider>
);
}
// Usage
function MyComponent() {
const { showAlert } = useContext(AlertContext);
const handleSave = async () => {
try {
await saveData();
showAlert({
type: 'success',
message: 'Data saved successfully',
autoDissmiss: 5000
});
} catch (error) {
showAlert({
type: 'error',
title: 'Save Failed',
message: error.message,
actions: [{
label: 'Retry',
onClick: handleSave
}]
});
}
};
}Best Practices
1. Clear hierarchy: Use consistent colors and icons for each type 2. Contextual placement: Page-level for global, inline for specific 3. Action clarity: Make primary actions obvious 4. Dismissal logic: Allow dismissal for non-critical alerts 5. Persistence: Keep errors visible until resolved 6. Mobile-first: Design for small screens first 7. Loading states: Show loading in alerts for async actions 8. Error details: Provide actionable error messages 9. Batch similar: Group related alerts when possible 10. Test accessibility: Verify with screen readers and keyboard
Related skills
FAQ
When should I use a modal versus a toast?
Use a modal dialog for critical blocking actions and a toast or snackbar for temporary success or info messages that do not block interaction.
What libraries does it recommend?
Sonner for toasts and Radix UI for modal dialogs in a modern React stack.