Overview
Accessibility
What ferry-ui does for accessibility, and what your code must do.
Overview
What ferry-ui does for accessibility, and what your code must do.
ferry-ui components have the roles, the focus management and the keyboard behavior. Some parts depend on your code.
| Subject | Behavior |
|---|---|
| Focus | Each control shows a focus ring when it gets the keyboard focus. |
| Fields | Field and FormRow set id, aria-describedby and aria-invalid on the control. |
| Icons | Components hide the icons of their icon props from assistive technology. |
| Loading | The loading props set aria-busy on the region. |
| Landmarks | App Shell gives the main element and the skip link. Do not add a second main element. |
| Navigation | Top Bar, Icon Rail, Mobile Nav and Inner Menu give nav elements with a label. |
| Motion | The animations of overlays respect prefers-reduced-motion. |
A control with only an icon needs an aria-label. This rule applies to Button, Toggle, ToggleGroupItem and each menu trigger.
A tooltip is not an accessible name. A button in a Hint keeps its aria-label.
import { Button, Hint } from 'ferry-ui'
import { Download, RefreshCw } from 'lucide-react'
export default function IconButtonNames() {
return (
<>
<Hint label="Refresh">
<Button variant="ghost" size="icon" icon={<RefreshCw />} aria-label="Refresh" />
</Hint>
<Hint label="Download CSV">
<Button size="icon" icon={<Download />} aria-label="Download CSV" />
</Hint>
</>
)
}If a screen repeats a control, name its target: Actions for INV-2041.
Use Field, or FormRow with htmlFor. They connect the label, the hint and the error to the control. A Label with htmlFor also names a control. For a field with no visible text, set aria-label.
Show each error as text. Type a full address in this field to remove the error.
Enter a valid email address.
import * as React from 'react'
import { Field, Input } from 'ferry-ui'
export default function FieldError() {
const [email, setEmail] = React.useState('maya@example')
const valid = /^\S+@\S+\.\S+$/.test(email)
return (
<Field
className="w-full max-w-xs"
label="Billing email"
hint="Invoices go to this address."
error={valid ? undefined : 'Enter a valid email address.'}
>
<Input type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
</Field>
)
}A group has no single control. Give aria-label or aria-labelledby to Radio Group, Toggle Group and Radio Card Group.
DialogTitle, AlertDialogTitle and SheetTitle are required. If a Dialog or a Sheet has no description, pass aria-describedby={undefined} to its content.
import {
Button,
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from 'ferry-ui'
export default function DialogWithTitle() {
return (
<Dialog>
<DialogTrigger asChild>
<Button>Keyboard shortcuts</Button>
</DialogTrigger>
{/* No description: tell the dialog that it has none. */}
<DialogContent size="sm" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Keyboard shortcuts</DialogTitle>
</DialogHeader>
<DialogBody>
<p className="text-[13px] text-foreground-light">Press Esc to close a dialog.</p>
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button variant="primary">Done</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}A color must come with text. Give this text to the components of Status, Metric Card and Icon Box.
import { MetricTrend, StatusBadge, StatusDot, UsageBar } from 'ferry-ui'
export default function StatusWithText() {
return (
<div className="flex w-full max-w-xs flex-col gap-4 text-[13px] text-foreground-light">
<div className="flex items-center justify-between">
Invoice INV-2041
<StatusBadge tone="destructive" label="Overdue" />
</div>
<div className="flex items-center justify-between">
<span className="flex items-center gap-2">
<StatusDot tone="success" /> 3 endpoints online
</span>
<StatusDot tone="warning" label="1 endpoint degraded" />
</div>
<div className="flex items-center justify-between">
Revenue
<MetricTrend>+12.5%</MetricTrend>
</div>
<UsageBar value={82} label="Storage used" />
</div>
)
}| Component | Rule |
|---|---|
StatusBadge | The label prop is required. |
StatusDot | Put text next to the dot, or set label. |
MetricTrend | Write the sign in the text: +12.5%. |
UsageBar | Set label. |
IconBox | If the icon has a meaning, set label. |
These rules apply to Table and Table States.
aria-label or with TableCaption.sr-only label in a header cell that has only an icon.rowLinkProps.aria-busy on TableBody while the skeleton rows show.ferry-ui hides each Skeleton from assistive technology. Set aria-busy on the region that loads.
| Component | Element |
|---|---|
PageHeader | h1. Use one for each page. |
PageSection | h2 |
FormCard, ResourceCard | h3. The titleAs prop of ResourceCard changes it. |
CardTitle | div. Put a heading in it if the page outline needs one. |
SearchInput, it clears the text.onCommandShortcut of AppShell. Use it to open the Command Menu.A Scroll Area with only text has no element that takes the focus. Pass tabIndex: 0, role: 'region' and an aria-label in viewportProps. The keyboard can then scroll the area.
Do not remove the focus rings. Give these classes to a custom interactive element.
<a href="#billing" className="rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
Billing
</a>Do not put an interactive element in another interactive element. For example, do not put a button in a RadioCard, or a link in a DescriptionItem row that has href.
pulse and spin only while work is in progress.motion-safe: or motion-reduce: to your own animations.