Apsara is built with accessibility as a core principle. All components are designed to be keyboard navigable, screen reader friendly, and WCAG compliant.
Foundation: Radix UI primitives
Apsara components are built on top of Radix UI primitives, which provide:
ARIA compliance - Proper ARIA attributes and roles
Keyboard navigation - Full keyboard support out of the box
Focus management - Smart focus handling for complex components
Screen reader support - Semantic HTML and announcements
Radix UI handles the complex accessibility patterns so you can focus on building your application.
ARIA attributes
All interactive components include appropriate ARIA attributes:
Example: Button states
import { Button } from "@raystack/apsara" ;
function DeleteButton () {
const [ isDeleting , setIsDeleting ] = useState ( false );
return (
< Button
onClick = { handleDelete }
disabled = { isDeleting }
aria-label = "Delete item"
aria-busy = { isDeleting }
>
Delete
</ Button >
);
}
Example: Accessible icons
Provide text alternatives for icon-only buttons:
import { IconButton } from "@raystack/apsara" ;
import { TrashIcon } from "@radix-ui/react-icons" ;
function Actions () {
return (
< IconButton aria-label = "Delete item" >
< TrashIcon />
</ IconButton >
);
}
Example: Form controls
Properly associate labels with form inputs:
import { Checkbox , Label } from "@raystack/apsara" ;
function Settings () {
return (
< div >
< Checkbox id = "notifications" />
< Label htmlFor = "notifications" >
Enable email notifications
</ Label >
</ div >
);
}
Keyboard navigation
Apsara components support standard keyboard interactions:
Interactive elements
Move focus to the next interactive element
Move focus to the previous interactive element
Activate buttons, links, and submit forms
Activate buttons and toggle checkboxes
Close dialogs, popovers, and dropdowns
Component-specific shortcuts
Esc - Close dialog
Focus is trapped within the dialog
Focus returns to trigger element on close
← / → - Navigate between tabs
Home / End - Jump to first/last tab
Tab - Move focus into tab panel
↓ / ↑ - Select next/previous option
Space - Select focused option
Space / Enter - Toggle panel
↓ / ↑ - Navigate between headers
Home / End - Jump to first/last header
Focus management
Components handle focus states properly with visible indicators:
.button:focus-visible {
outline : 1 px solid var ( --rs-color-border-accent-emphasis );
}
.button:focus {
outline : none ;
}
This pattern:
Shows focus indicator for keyboard navigation (:focus-visible)
Hides focus indicator for mouse clicks (:focus)
Uses theme-aware colors for the outline
Never remove focus indicators without providing an alternative visual cue. Focus indicators are essential for keyboard navigation.
Focus trap
Modal components like Dialog automatically trap focus:
import { Dialog } from "@raystack/apsara" ;
function ConfirmDialog () {
return (
< Dialog >
< Dialog.Trigger > Delete Account </ Dialog.Trigger >
< Dialog.Content >
{ /* Focus is trapped here - Tab only cycles through these elements */ }
< Dialog.Title > Are you sure? </ Dialog.Title >
< Dialog.Description >
This action cannot be undone.
</ Dialog.Description >
< Dialog.Close > Cancel </ Dialog.Close >
< Button variant = "danger" > Delete </ Button >
</ Dialog.Content >
</ Dialog >
);
}
Screen reader support
Visually hidden text
Use the .sr-only class for screen reader-only content:
import { Button } from "@raystack/apsara" ;
import { MagnifyingGlassIcon } from "@radix-ui/react-icons" ;
function SearchButton () {
return (
< Button >
< MagnifyingGlassIcon aria-hidden = "true" />
< span className = "sr-only" > Search </ span >
</ Button >
);
}
The .sr-only utility class (from badge.module.css):
.sr-only {
position : absolute ;
width : 1 px ;
height : 1 px ;
padding : 0 ;
margin : -1 px ;
overflow : hidden ;
clip : rect ( 0 , 0 , 0 , 0 );
white-space : nowrap ;
border : 0 ;
}
Live regions
Announce dynamic content changes:
function NotificationList () {
const [ notifications , setNotifications ] = useState ([]);
return (
< div >
< div aria-live = "polite" aria-atomic = "true" className = "sr-only" >
{ notifications . length } new notifications
</ div >
< ul >
{ notifications . map ( n => (
< li key = { n . id } > { n . message } </ li >
)) }
</ ul >
</ div >
);
}
Semantic HTML
Apsara components use semantic HTML elements:
// Button component renders <button>
< Button > Click me </ Button >
// → <button class="button">Click me</button>
// Navigation uses proper list structure
< Breadcrumb >
< Breadcrumb.Item > Home </ Breadcrumb.Item >
< Breadcrumb.Item > Docs </ Breadcrumb.Item >
</ Breadcrumb >
// → <nav aria-label="Breadcrumb"><ol>...</ol></nav>
Color contrast
Apsara’s default themes meet WCAG AA standards (4.5:1 contrast ratio for normal text):
/* Light theme - high contrast */
:root {
--foreground-base : #3c4347 ; /* Text */
--background-base : #fbfcfd ; /* Background */
/* Contrast ratio: ~12:1 */
}
/* Dark theme - high contrast */
html [ data-theme = "dark" ] {
--foreground-base : #ecedee ; /* Text */
--background-base : #151718 ; /* Background */
/* Contrast ratio: ~14:1 */
}
When customizing colors, use a contrast checker to ensure WCAG compliance:
AA standard : 4.5:1 for normal text, 3:1 for large text
AAA standard : 7:1 for normal text, 4.5:1 for large text
Color variants
Semantic color variants maintain accessibility:
import { Badge } from "@raystack/apsara" ;
function StatusBadge ({ status }) {
return (
<>
< Badge variant = "success" > Active </ Badge >
{ /* Green background with sufficient contrast */ }
< Badge variant = "danger" > Error </ Badge >
{ /* Red background with sufficient contrast */ }
< Badge variant = "neutral" > Pending </ Badge >
{ /* Gray background with sufficient contrast */ }
</>
);
}
Motion and animations
Respect user’s motion preferences:
.button {
transition : all 0.2 s ease-in-out ;
}
@media (prefers-reduced-motion: reduce) {
.button {
transition : none ;
}
}
The ThemeProvider supports disabling transitions:
import { ThemeProvider } from "@raystack/apsara" ;
function App () {
return (
< ThemeProvider disableTransitionOnChange >
< YourApp />
</ ThemeProvider >
);
}
Form accessibility
Validation and errors
Associate error messages with form fields:
import { Input , Text } from "@raystack/apsara" ;
function EmailInput () {
const [ error , setError ] = useState ( "" );
return (
< div >
< Label htmlFor = "email" > Email </ Label >
< Input
id = "email"
type = "email"
aria-invalid = { !! error }
aria-describedby = { error ? "email-error" : undefined }
/>
{ error && (
< Text id = "email-error" role = "alert" >
{ error }
</ Text >
) }
</ div >
);
}
Required fields
Indicate required fields clearly:
function SignupForm () {
return (
< form >
< Label htmlFor = "username" >
Username < span aria-label = "required" > * </ span >
</ Label >
< Input id = "username" required aria-required = "true" />
</ form >
);
}
Testing accessibility
Automated testing
Use tools like axe or jest-axe :
import { render } from "@testing-library/react" ;
import { axe , toHaveNoViolations } from "jest-axe" ;
import { Button } from "@raystack/apsara" ;
expect . extend ( toHaveNoViolations );
test ( "Button has no accessibility violations" , async () => {
const { container } = render ( < Button > Click me </ Button > );
const results = await axe ( container );
expect ( results ). toHaveNoViolations ();
});
Manual testing
Test with real assistive technologies:
Keyboard navigation - Navigate your app using only Tab, Enter, Space, and Arrow keys
Screen readers - Test with NVDA (Windows), JAWS (Windows), or VoiceOver (macOS/iOS)
Zoom - Test at 200% zoom to ensure content remains readable
High contrast mode - Test with Windows High Contrast or browser extensions
Best practices
Use semantic HTML Choose the right HTML element for the job (button vs div, nav vs div, etc.)
Provide text alternatives Add alt text for images and aria-label for icon buttons
Maintain focus order Ensure tab order follows visual layout
Test with users Include people with disabilities in user testing
Document shortcuts List keyboard shortcuts in your app’s help section
Avoid ARIA overuse Use native HTML features first, ARIA as enhancement
Resources
Radix UI Learn about the accessible primitives Apsara is built on
WCAG Guidelines Web Content Accessibility Guidelines reference
ARIA Authoring Practices Design patterns and widgets using ARIA
WebAIM Accessibility training and resources
Common patterns
Skip links
Add skip links for keyboard users:
function Layout () {
return (
<>
< a href = "#main-content" className = "sr-only sr-only-focusable" >
Skip to main content
</ a >
< nav > { /* Navigation */ } </ nav >
< main id = "main-content" >
{ /* Main content */ }
</ main >
</>
);
}
.sr-only-focusable:focus {
position : static ;
width : auto ;
height : auto ;
margin : 0 ;
overflow : visible ;
clip : auto ;
}
Loading states
Announce loading states to screen readers:
import { Button } from "@raystack/apsara" ;
function SubmitButton () {
const [ isLoading , setIsLoading ] = useState ( false );
return (
< Button
loading = { isLoading }
aria-live = "polite"
aria-busy = { isLoading }
>
{ isLoading ? "Saving..." : "Save" }
</ Button >
);
}
Data tables
Use proper table markup:
import { Table } from "@raystack/apsara" ;
function UserTable () {
return (
< Table >
< caption className = "sr-only" > List of users </ caption >
< thead >
< tr >
< th scope = "col" > Name </ th >
< th scope = "col" > Email </ th >
< th scope = "col" > Role </ th >
</ tr >
</ thead >
< tbody >
{ /* Table rows */ }
</ tbody >
</ Table >
);
}
Related resources
Styling Learn about focus states and visual indicators
Dark mode Ensure accessibility in both light and dark themes