Building accessible web applications with ARIA, keyboard navigation, and screen reader support
# Web Accessibility Patterns for Google Antigravity
Build accessible applications with Google Antigravity's Gemini 3 engine. This guide covers ARIA attributes, keyboard navigation, focus management, and screen reader compatibility.
## Accessible Button Component
```typescript
// components/Button.tsx
import { forwardRef, ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
variant = 'primary',
size = 'md',
isLoading,
leftIcon,
rightIcon,
disabled,
className,
...props
},
ref
) => {
const isDisabled = disabled || isLoading;
return (
<button
ref={ref}
disabled={isDisabled}
aria-disabled={isDisabled}
aria-busy={isLoading}
className={cn(
'inline-flex items-center justify-center font-medium rounded-lg',
'transition-colors focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed',
{
'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500':
variant === 'primary',
'bg-gray-200 text-gray-900 hover:bg-gray-300 focus-visible:ring-gray-500':
variant === 'secondary',
'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500':
variant === 'danger',
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
},
className
)}
{...props}
>
{isLoading ? (
<>
<span className="sr-only">Loading</span>
<svg
className="animate-spin h-5 w-5 mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
</>
) : (
leftIcon && <span className="mr-2" aria-hidden="true">{leftIcon}</span>
)}
{children}
{rightIcon && !isLoading && (
<span className="ml-2" aria-hidden="true">{rightIcon}</span>
)}
</button>
);
}
);
Button.displayName = 'Button';
```
## Accessible Modal Dialog
```typescript
// components/Modal.tsx
import { useEffect, useRef, useCallback, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
description?: string;
children: ReactNode;
initialFocus?: React.RefObject<HTMLElement>;
}
export function Modal({
isOpen,
onClose,
title,
description,
children,
initialFocus,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
const titleId = useId();
const descriptionId = useId();
// Focus trap
const trapFocus = useCallback((e: KeyboardEvent) => {
if (e.key !== 'Tab' || !modalRef.current) return;
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}, []);
// Handle escape key
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
}, [onClose]);
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement as HTMLElement;
// Focus initial element or first focusable
if (initialFocus?.current) {
initialFocus.current.focus();
} else {
const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea'
);
firstFocusable?.focus();
}
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Add event listeners
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keydown', trapFocus);
}
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keydown', trapFocus);
// Restore focus
previousFocus.current?.focus();
};
}, [isOpen, handleKeyDown, trapFocus, initialFocus]);
if (!isOpen) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="presentation"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
aria-hidden="true"
onClick={onClose}
/>
{/* Dialog */}
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
className="relative bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6"
>
<h2 id={titleId} className="text-xl font-semibold mb-2">
{title}
</h2>
{description && (
<p id={descriptionId} className="text-gray-600 mb-4">
{description}
</p>
)}
{children}
<button
onClick={onClose}
className="absolute top-4 right-4 p-1 rounded hover:bg-gray-100"
aria-label="Close dialog"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>,
document.body
);
}
```
## Accessible Dropdown Menu
```typescript
// components/DropdownMenu.tsx
import { useState, useRef, useCallback, useEffect, ReactNode } from 'react';
interface DropdownMenuProps {
trigger: ReactNode;
items: Array<{
label: string;
onClick: () => void;
icon?: ReactNode;
disabled?: boolean;
}>;
}
export function DropdownMenu({ trigger, items }: DropdownMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLUListElement>(null);
const menuId = useId();
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(0);
} else {
setActiveIndex((prev) =>
prev < items.length - 1 ? prev + 1 : 0
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (isOpen) {
setActiveIndex((prev) =>
prev > 0 ? prev - 1 : items.length - 1
);
}
break;
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen && activeIndex >= 0) {
items[activeIndex].onClick();
setIsOpen(false);
} else {
setIsOpen(true);
setActiveIndex(0);
}
break;
case 'Escape':
setIsOpen(false);
buttonRef.current?.focus();
break;
case 'Tab':
setIsOpen(false);
break;
case 'Home':
e.preventDefault();
setActiveIndex(0);
break;
case 'End':
e.preventDefault();
setActiveIndex(items.length - 1);
break;
}
},
[isOpen, activeIndex, items]
);
// Close on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(e.target as Node) &&
!buttonRef.current?.contains(e.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="relative inline-block">
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={menuId}
className="px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200"
>
{trigger}
</button>
{isOpen && (
<ul
ref={menuRef}
id={menuId}
role="menu"
aria-orientation="vertical"
onKeyDown={handleKeyDown}
className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border py-1"
>
{items.map((item, index) => (
<li
key={index}
role="menuitem"
tabIndex={activeIndex === index ? 0 : -1}
aria-disabled={item.disabled}
onClick={() => {
if (!item.disabled) {
item.onClick();
setIsOpen(false);
}
}}
className={cn(
'px-4 py-2 flex items-center cursor-pointer',
activeIndex === index && 'bg-gray-100',
item.disabled && 'opacity-50 cursor-not-allowed'
)}
>
{item.icon && (
<span className="mr-2" aria-hidden="true">
{item.icon}
</span>
)}
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
```
## Skip Links
```typescript
// components/SkipLinks.tsx
export function SkipLinks() {
return (
<div className="sr-only focus-within:not-sr-only">
<a
href="#main-content"
className="absolute top-0 left-0 z-50 px-4 py-2 bg-blue-600 text-white focus:outline-none"
>
Skip to main content
</a>
<a
href="#main-navigation"
className="absolute top-0 left-32 z-50 px-4 py-2 bg-blue-600 text-white focus:outline-none"
>
Skip to navigation
</a>
</div>
);
}
```
## Best Practices
Google Antigravity's Gemini 3 engine recommends these accessibility patterns: Use semantic HTML elements first. Implement proper focus management. Add keyboard navigation for all interactive elements. Use ARIA attributes only when needed. Test with screen readers and keyboard-only navigation.This Accessibility prompt is ideal for developers working on:
By using this prompt, you can save hours of manual coding and ensure best practices are followed from the start. It's particularly valuable for teams looking to maintain consistency across their accessibility implementations.
Yes! All prompts on Antigravity AI Directory are free to use for both personal and commercial projects. No attribution required, though it's always appreciated.
This prompt works excellently with Claude, ChatGPT, Cursor, GitHub Copilot, and other modern AI coding assistants. For best results, use models with large context windows.
You can modify the prompt by adding specific requirements, constraints, or preferences. For Accessibility projects, consider mentioning your framework version, coding style, and any specific libraries you're using.