Overview
Accessibility is a core requirement in the User Interface Wiki. All components must follow WCAG guidelines, use semantic HTML, provide proper ARIA attributes, support keyboard navigation, and respect user motion preferences.
Semantic HTML
Use Appropriate Elements
Always use semantic HTML elements that match the component’s purpose:
Correct: Semantic elements
Incorrect: Generic divs
function Navigation () {
return (
< nav aria-label = "Main navigation" >
< ul >
< li >< a href = "/" > Home </ a ></ li >
< li >< a href = "/docs" > Documentation </ a ></ li >
</ ul >
</ nav >
);
}
function Article () {
return (
< article >
< h1 > Article Title </ h1 >
< p > Content goes here... </ p >
</ article >
);
}
Interactive Elements
Buttons vs Links:
Use for actions that change application state (open modal, submit form, toggle state)
Use for navigation to different pages or sections
Correct: Proper element choice
Incorrect: Wrong elements
// Action that changes state
< button onClick = { handleOpen } > Open Dialog </ button >
// Navigation to another page
< a href = "/about" > About Us </ a >
Semantic Component Example
From the actual Button component:
components/button/index.tsx
import { Button as BaseButton } from "@base-ui/react/button" ;
function Button ({ children , ... props } : ButtonProps ) {
return (
< BaseButton
nativeButton = { true } // Ensures proper <button> element
className = { styles . button }
{ ... props }
>
{ children }
</ BaseButton >
);
}
Base UI components provide semantic HTML by default, which is why we use them as primitives.
ARIA Attributes
When to Use ARIA
ARIA attributes supplement semantic HTML when native elements are insufficient:
First Rule of ARIA: If you can use a native HTML element or attribute with the semantics and behavior you require already built in, do so. Only use ARIA when semantic HTML is not enough.
Common ARIA Patterns
Labels and Descriptions
// Label for interactive elements without visible text
< button aria-label = "Close dialog" >
< XIcon />
</ button >
// Description for additional context
< input
type = "password"
aria-label = "Password"
aria-describedby = "password-requirements"
/>
< div id = "password-requirements" >
Must be at least 8 characters
</ div >
Live Regions
Announce dynamic content changes to screen readers:
function Toast ({ message } : ToastProps ) {
return (
< div
role = "status"
aria-live = "polite"
aria-atomic = "true"
>
{ message }
</ div >
);
}
function ErrorAlert ({ error } : ErrorAlertProps ) {
return (
< div
role = "alert" // Implicitly aria-live="assertive"
aria-atomic = "true"
>
{ error }
</ div >
);
}
Tab Pattern
function Tabs ({ tabs , activeTab } : TabsProps ) {
return (
< div >
< div role = "tablist" aria-label = "Content sections" >
{ tabs . map (( tab ) => (
< button
key = { tab . id }
role = "tab"
aria-selected = { activeTab === tab . id }
aria-controls = { `panel- ${ tab . id } ` }
id = { `tab- ${ tab . id } ` }
>
{ tab . label }
</ button >
)) }
</ div >
{ tabs . map (( tab ) => (
< div
key = { tab . id }
role = "tabpanel"
id = { `panel- ${ tab . id } ` }
aria-labelledby = { `tab- ${ tab . id } ` }
hidden = { activeTab !== tab . id }
>
{ tab . content }
</ div >
)) }
</ div >
);
}
Dialog/Modal Pattern
function Dialog ({ isOpen , title , children } : DialogProps ) {
return (
< div
role = "dialog"
aria-modal = "true"
aria-labelledby = "dialog-title"
hidden = { ! isOpen }
>
< h2 id = "dialog-title" > { title } </ h2 >
{ children }
< button aria-label = "Close dialog" > ✕ </ button >
</ div >
);
}
Real Example: Popover Component
From the actual codebase:
components/popover/index.tsx
import { Popover as BasePopover } from "@base-ui/react/popover" ;
// Base UI handles all ARIA attributes automatically:
// - aria-haspopup
// - aria-expanded
// - aria-controls
// - role="dialog"
function PopoverTrigger ({ ... props } : PopoverTriggerProps ) {
return < BasePopover.Trigger className = { styles . trigger } { ... props } /> ;
}
function PopoverPopup ({ ... props } : PopoverPopupProps ) {
return < BasePopover.Popup className = { styles . popup } { ... props } /> ;
}
Base UI components handle complex ARIA patterns correctly, which is a major reason we use them.
Focus Management
Visible Focus Indicators
All interactive elements must have visible focus states:
components/button/styles.module.css
.button {
/* Base styles */
border-radius : 6 px ;
transition :
color 0.2 s ease ,
background-color 0.2 s ease ,
outline 0.2 s ease ;
}
.button:focus-visible {
outline : 2 px solid var ( --gray-12 );
outline-offset : 2 px ;
}
Use :focus-visible instead of :focus to show outlines only for keyboard navigation, not mouse clicks.
Focus Outline Pattern
Consistent focus styling across components:
/* High contrast outline for keyboard focus */
:focus-visible {
outline : 2 px solid var ( --gray-12 );
outline-offset : 2 px ;
}
/* Remove default browser outline */
:focus {
outline : none ;
}
Keyboard Navigation
Ensure all interactive features are keyboard accessible:
Move focus forward through interactive elements
Move focus backward through interactive elements
Activate buttons and links
Close dialogs, popovers, and modals
Navigate within composite widgets (tabs, menus, listboxes)
Focus Trap for Modals
When a modal opens, trap focus within it:
import { useEffect , useRef } from "react" ;
function Dialog ({ isOpen , onClose , children } : DialogProps ) {
const dialogRef = useRef < HTMLDivElement >( null );
useEffect (() => {
if ( ! isOpen ) return ;
const dialog = dialogRef . current ;
if ( ! dialog ) return ;
// Store the element that opened the dialog
const previousActiveElement = document . activeElement as HTMLElement ;
// Focus the first focusable element in the dialog
const focusableElements = dialog . querySelectorAll (
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements [ 0 ] as HTMLElement ;
firstElement ?. focus ();
// Return focus when dialog closes
return () => {
previousActiveElement ?. focus ();
};
}, [ isOpen ]);
return (
< div
ref = { dialogRef }
role = "dialog"
aria-modal = "true"
hidden = { ! isOpen }
>
{ children }
</ div >
);
}
Base UI Popover, Dialog, and other overlay components handle focus management automatically.
Skip Links
Provide skip navigation for keyboard users:
. skip - to - content {
position : absolute ;
top : - 100 px ; /* Hidden by default */
left : 50 % ;
z - index : 100 ;
padding : 12 px 24 px ;
font - size : 14 px ;
font - weight : var (-- font -weight-medium);
color : var (-- gray -1);
text - decoration : none ;
background : var (-- gray -12);
border - radius : 8 px ;
transform : translateX ( - 50 % );
transition : top 0.2 s ease ;
}
. skip - to - content : focus {
top : 16 px ; /* Visible when focused */
}
function Layout ({ children } : LayoutProps ) {
return (
<>
< a href = "#main-content" className = "skip-to-content" >
Skip to content
</ a >
< Navigation />
< main id = "main-content" >
{ children }
</ main >
</>
);
}
Reduced Motion
Respect User Preferences
Always respect the prefers-reduced-motion media query:
.animated {
transition :
transform 0.3 s ease ,
opacity 0.3 s ease ;
}
@media (prefers-reduced-motion: reduce) {
.animated {
animation : none ;
transition : none ;
}
}
Motion Library Support
Use Motion’s built-in reduced motion support:
import { motion } from "motion/react" ;
< motion.div
initial = { { opacity: 0 , y: 20 } }
animate = { { opacity: 1 , y: 0 } }
transition = { { duration: 0.3 } }
>
Content
</ motion.div >
Motion automatically disables animations when prefers-reduced-motion: reduce is set.
Manual Reduced Motion Check
For custom animations:
import { useReducedMotion } from "motion/react" ;
function AnimatedComponent () {
const shouldReduceMotion = useReducedMotion ();
return (
< motion.div
animate = { {
scale: shouldReduceMotion ? 1 : [ 1 , 1.1 , 1 ],
} }
transition = { {
duration: shouldReduceMotion ? 0 : 0.6 ,
} }
>
Content
</ motion.div >
);
}
CSS Transitions with Reduced Motion
Pattern from the Figure component:
components/figure/styles.module.css
.wrapper {
border-radius : var ( --prose-block-radius );
transition : transform 0.2 s ease ;
}
.wrapper:hover {
transform : scale ( 1.02 );
}
@media (prefers-reduced-motion: reduce) {
.wrapper {
transition : none ;
}
.wrapper:hover {
transform : none ;
}
}
Color Contrast
WCAG AA Compliance
The Radix UI Colors used in the theme meet WCAG AA contrast requirements:
/* Text on backgrounds */
.light-text {
color : var ( --gray-11 ); /* AA compliant on gray-1 to gray-3 */
}
.dark-text {
color : var ( --gray-12 ); /* AAA compliant on gray-1 to gray-3 */
}
/* Interactive elements */
.button {
color : var ( --gray-10 ); /* AA compliant */
background : var ( --gray-1 );
}
.button:hover {
color : var ( --gray-12 ); /* AAA compliant */
}
Radix UI color scales are designed so that:
Steps 1-2: Backgrounds, subtle fills
Steps 3-5: UI borders and separators
Steps 6-8: Hovered UI backgrounds
Steps 9-10: Solid backgrounds, hovered text
Steps 11-12: Low-contrast text, high-contrast text
Testing Contrast
Always verify color combinations meet minimum contrast ratios:
Normal text: 4.5:1 (WCAG AA)
Large text: 3:1 (WCAG AA)
Interactive elements: 3:1 (WCAG AA)
Screen Reader Support
Descriptive Labels
// Icon-only button
< button aria-label = "Close dialog" >
< XIcon aria-hidden = "true" />
</ button >
// Search input
< input
type = "search"
aria-label = "Search documentation"
placeholder = "Search..."
/>
Hide Decorative Elements
// Decorative icon
< div >
< CheckIcon aria-hidden = "true" />
< span > Task completed </ span >
</ div >
// Decorative background shape
< div aria-hidden = "true" className = "background-orb" />
Announce Dynamic Changes
function SearchResults ({ results , isLoading } : SearchResultsProps ) {
return (
<>
< div role = "status" aria-live = "polite" aria-atomic = "true" >
{ isLoading ? "Searching..." : `Found ${ results . length } results` }
</ div >
< ul >
{ results . map (( result ) => (
< li key = { result . id } > { result . title } </ li >
)) }
</ ul >
</>
);
}
Alt Text for Images
Provide descriptive alt text for meaningful images:
import Image from "next/image" ;
// Descriptive alt for content images
< Image
src = "/content/animation-example.gif"
alt = "Ball bouncing with squash and stretch, demonstrating the first principle of animation"
width = { 640 }
height = { 360 }
/>
// Empty alt for decorative images
< Image
src = "/decorative-pattern.svg"
alt = ""
width = { 100 }
height = { 100 }
/>
Testing Checklist
Before shipping a component, verify:
Tools for Testing
Keyboard: Test with keyboard only (no mouse)
Screen Reader: Test with VoiceOver (macOS), NVDA (Windows), or JAWS
Browser DevTools: Lighthouse accessibility audit
axe DevTools: Browser extension for automated accessibility testing
Contrast Checker: WebAIM Contrast Checker for color verification
Resources