# 4.1 Assistive Technology Compatibility
Web3 interfaces must work with screen readers, switches, and voice control.
## Success Criteria
### 4.1.1 Proper ARIA Roles (Level A)
**Requirement**: Wallet modals, transaction dialogs, and custom controls must use proper ARIA roles, states, and properties.
**Intent**: Custom Web3 UI components need ARIA to be understood by assistive technology.
**Benefits**:
- **Screen reader users**: Components announced correctly
- **All AT users**: Proper semantics enable navigation
**Techniques**:
```tsx
// Wallet connection modal
<div
role="dialog"
aria-modal="true"
aria-labelledby="wallet-modal-title"
aria-describedby="wallet-modal-desc"
>
<h2 id="wallet-modal-title">Connect Wallet</h2>
<p id="wallet-modal-desc">Choose a wallet to connect to this app</p>
{/* Wallet list */}
<div role="listbox" aria-label="Available wallets">
{wallets.map((wallet, i) => (
<button
key={wallet.id}
role="option"
aria-selected={selectedWallet === wallet.id}
aria-disabled={!wallet.installed}
>
{wallet.name}
{!wallet.installed && " (Not installed)"}
</button>
))}
</div>
</div>
// Token selector combobox
<div className="token-selector">
<label id="token-label">Select token</label>
<button
role="combobox"
aria-labelledby="token-label"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls="token-listbox"
onClick={toggleDropdown}
>
{selectedToken?.symbol || "Select token"}
</button>
{isOpen && (
<ul
id="token-listbox"
role="listbox"
aria-labelledby="token-label"
>
{tokens.map(token => (
<li
key={token.address}
role="option"
aria-selected={selectedToken?.address === token.address}
onClick={() => selectToken(token)}
>
{token.name} ({token.symbol})
</li>
))}
</ul>
)}
</div>
// Swap toggle button
<button
aria-label="Swap input and output tokens"
aria-pressed={isSwapped}
onClick={swapTokens}
>
⇅
</button>
```
**Failures**:
- Custom controls without ARIA roles
- Missing aria-modal on dialogs
- No aria-expanded on dropdowns
- Using divs for interactive elements
---
### 4.1.2 Dynamic Content Announcements (Level A)
**Requirement**: Dynamic balance updates, transaction status changes, and price updates must be announced appropriately using ARIA live regions.
**Intent**: Screen reader users need to know when content changes without visual access.
**Benefits**:
- **Screen reader users**: Aware of changes
- **All users**: Consistent state awareness
**Techniques**:
```tsx
// Balance updates - polite (non-urgent)
<div
aria-live="polite"
aria-atomic="true"
className="balance-display"
>
Balance: {formattedBalance} {symbol}
</div>
// Transaction status - assertive (important)
<div
role="status"
aria-live="assertive"
aria-atomic="true"
>
{txStatus === 'pending' && 'Transaction pending...'}
{txStatus === 'confirmed' && 'Transaction confirmed!'}
{txStatus === 'failed' && 'Transaction failed. Please try again.'}
</div>
// Price updates - polite with rate limiting
function usePriceAnnouncement(price: number, symbol: string) {
const [lastAnnounced, setLastAnnounced] = useState(price);
const announcementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Only announce significant changes (>1%)
const changePercent = Math.abs((price - lastAnnounced) / lastAnnounced * 100);
if (changePercent > 1) {
setLastAnnounced(price);
// Live region will announce
}
}, [price, lastAnnounced]);
return (
<div
ref={announcementRef}
aria-live="polite"
className="sr-only"
>
{symbol} price: ${price.toFixed(2)}
</div>
);
}
// Error announcements - assertive role="alert"
<div role="alert" aria-live="assertive">
{error && <p>Error: {error.message}</p>}
</div>
```
**Failures**:
- No live regions for dynamic content
- Everything set to assertive (too noisy)
- Rapid updates overwhelming users
- Status changes not announced
---
### 4.1.3 Accessible Table Markup (Level AA)
**Requirement**: Token lists, transaction histories, and DeFi positions must use proper table markup with headers and accessible structure.
**Intent**: Tables are complex. Proper markup enables screen readers to navigate cells meaningfully.
**Benefits**:
- **Screen reader users**: Navigate by row/column
- **All users**: Clear data relationships
**Techniques**:
```tsx
// Token list
<table aria-label="Your token holdings">
<thead>
<tr>
<th scope="col">Token</th>
<th scope="col">Balance</th>
<th scope="col">Value</th>
<th scope="col">24h Change</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{tokens.map(token => (
<tr key={token.address}>
<th scope="row">
<TokenDisplay token={token} />
</th>
<td>{token.balance} {token.symbol}</td>
<td>${token.value.toFixed(2)}</td>
<td>
<PriceChange value={token.change24h} />
</td>
<td>
<button aria-label={`Send ${token.symbol}`}>Send</button>
<button aria-label={`Swap ${token.symbol}`}>Swap</button>
</td>
</tr>
))}
</tbody>
</table>
// Transaction history with sortable columns
<table aria-label="Transaction history">
<thead>
<tr>
<th scope="col">
<button
aria-sort={sortColumn === 'date' ? sortDirection : 'none'}
onClick={() => sortBy('date')}
>
Date
<SortIcon direction={sortColumn === 'date' ? sortDirection : null} />
</button>
</th>
<th scope="col">Type</th>
<th scope="col">
<button
aria-sort={sortColumn === 'amount' ? sortDirection : 'none'}
onClick={() => sortBy('amount')}
>
Amount
</button>
</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{transactions.map(tx => (
<tr key={tx.hash}>
<td>{formatDate(tx.timestamp)}</td>
<th scope="row">{tx.type}</th>
<td>{tx.amount} {tx.symbol}</td>
<td>
<StatusBadge status={tx.status} />
</td>
</tr>
))}
</tbody>
</table>
// Caption for context
<table>
<caption>
Your DeFi positions across all protocols
<span className="sr-only">
Use column headers to understand data in each cell
</span>
</caption>
{/* ... */}
</table>
```
**Failures**:
- Using divs/spans for tabular data
- Missing thead/tbody
- No scope on headers
- Sort state not announced
---
### 4.1.4 Configurable Price Feed Announcements (Level AAA)
**Requirement**: Real-time price feeds have configurable announcement frequency so users can control how often updates are spoken.
**Intent**: Constant price updates are overwhelming. User control enables personalization.
**Benefits**:
- **Screen reader users**: Not overwhelmed by updates
- **All users**: Control notification frequency
**Techniques**:
```tsx
interface PriceAnnouncementSettings {
enabled: boolean;
frequency: 'realtime' | 'significant' | 'manual' | 'off';
significantChangeThreshold: number; // percentage
cooldownSeconds: number;
}
function PriceDisplay({
price,
symbol,
settings
}: {
price: number;
symbol: string;
settings: PriceAnnouncementSettings;
}) {
const [announcement, setAnnouncement] = useState('');
const lastAnnouncedRef = useRef({ price, time: Date.now() });
useEffect(() => {
if (!settings.enabled || settings.frequency === 'off') return;
const now = Date.now();
const timeSinceLastAnnouncement = (now - lastAnnouncedRef.current.time) / 1000;
if (timeSinceLastAnnouncement < settings.cooldownSeconds) return;
if (settings.frequency === 'realtime') {
setAnnouncement(`${symbol}: $${price.toFixed(2)}`);
lastAnnouncedRef.current = { price, time: now };
} else if (settings.frequency === 'significant') {
const changePercent = Math.abs(
(price - lastAnnouncedRef.current.price) / lastAnnouncedRef.current.price * 100
);
if (changePercent >= settings.significantChangeThreshold) {
const direction = price > lastAnnouncedRef.current.price ? 'up' : 'down';
setAnnouncement(
`${symbol} ${direction} ${changePercent.toFixed(1)}% to $${price.toFixed(2)}`
);
lastAnnouncedRef.current = { price, time: now };
}
}
}, [price, symbol, settings]);
return (
<>
<span className="price-display">
${price.toFixed(2)}
</span>
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
{settings.frequency === 'manual' && (
<button
aria-label={`Announce ${symbol} price`}
onClick={() => setAnnouncement(`${symbol}: $${price.toFixed(2)}`)}
>
🔊
</button>
)}
</>
);
}
// Settings UI
<PriceAnnouncementSettings>
<fieldset>
<legend>Price announcement frequency</legend>
<label>
<input
type="radio"
name="frequency"
value="realtime"
checked={settings.frequency === 'realtime'}
onChange={() => updateFrequency('realtime')}
/>
Real-time (all updates)
</label>
<label>
<input
type="radio"
name="frequency"
value="significant"
/>
Significant changes only
{settings.frequency === 'significant' && (
<label>
Threshold:
<input
type="number"
value={settings.significantChangeThreshold}
onChange={updateThreshold}
min={1}
max={50}
/>
%
</label>
)}
</label>
<label>
<input
type="radio"
name="frequency"
value="manual"
/>
Manual only (press button to hear)
</label>
<label>
<input
type="radio"
name="frequency"
value="off"
/>
Off
</label>
</fieldset>
<label>
Minimum seconds between announcements:
<input
type="number"
value={settings.cooldownSeconds}
onChange={updateCooldown}
min={1}
/>
</label>
</PriceAnnouncementSettings>
```
**Failures**:
- No control over announcement frequency
- All updates announced immediately
- Cannot disable announcements
- No threshold configuration
---
## Testing Checklist
- [ ] All modals have role="dialog" and aria-modal
- [ ] Custom controls have appropriate ARIA roles
- [ ] Live regions announce important changes
- [ ] Live region priority matches importance
- [ ] Tables have proper headers and scope
- [ ] Sort state announced for sortable columns
- [ ] Price announcement frequency is configurable
- [ ] Test with NVDA, JAWS, and VoiceOver
## Related Components
- [WalletModal.tsx](../../components/WalletModal.tsx)
- [TokenSelector.tsx](../../components/TokenSelector.tsx)
- [TokenTable.tsx](../../components/TokenTable.tsx)
- [PriceDisplay.tsx](../../components/PriceDisplay.tsx)