Accessibility

Design System Accessibility Guide

The rules and patterns every Viabilio component must follow. Target: WCAG 2.1 level AA. Each section offers a copyable example.

Principles

Four pillars that govern every Viabilio interaction.

  1. Perceivable

    AA contrast, resizable text, never color alone to convey meaning.

  2. Operable

    Fully keyboard-operable, logical order, targets ≥ 44×44 px on mobile.

  3. Understandable

    Explicit labels, clear error messages, language declared on <html>.

  4. Robust

    Radix/shadcn primitives rather than custom widgets, ARIA only as a last resort.

Contrast

Always use semantic tokens. Minimum ratios: 4.5:1 for text, 3:1 for components and large text.

Primary text
text-foreground · bg-background
Secondary text
text-muted-foreground · bg-background
Primary CTA
text-primary-foreground · bg-primary
Success
text-white · bg-brand-green
Warning
text-brand-ink · bg-brand-yellow
Destructive
text-destructive-foreground · bg-destructive
Avoid
<p className="text-gray-300">Result</p>
<p className="text-muted-foreground/50">Details</p>
Do
<p className="text-foreground">Result</p>
<p className="text-muted-foreground">Details</p>

Opacities below 60% on text almost always break the AA ratio. Prefer a dedicated token instead.

Visible Focus

A focus ring is mandatory on every interactive element. Never remove the outline without replacing it.

Link

Use the Tab key to test: each element above should show a visible ring on a white background.

Avoid
<button className="outline-none focus:outline-none">
  Submit
</button>
Do
<button
  className="rounded-md px-3 py-2 focus-visible:outline-none
    focus-visible:ring-2 focus-visible:ring-ring
    focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
  Submit
</button>

Our Button/Input/Card components already include these styles — only add these classes for custom elements.

Keyboard Navigation

Every action must be reachable and triggerable by keyboard, in a logical order.

  • Tab / Shift+Tab

    Moves through interactive elements

  • Enter / Space

    Activates a button

  • Escape

    Closes a dialog or menu

  • Arrow keys

    Navigates a menu, list, or select

Avoid
<div onClick={onSubmit}>Submit</div>
Do
<Button onClick={onSubmit}>Submit</Button>

// Or for a truly custom element:
<div
  role="button"
  tabIndex={0}
  onClick={onSubmit}
  onKeyDown={(e) => {
    if (e.key === "Enter" || e.key === " ") onSubmit();
  }}
>
  Submit
</div>

Always prefer a <button> or the Button component over an interactive div.

ARIA & Labels

Every control has an accessible name. Icon-only controls must be announced via a label.

Avoid
<Button size="icon" variant="ghost">
  <X />
</Button>
Do
<Button
  size="icon"
  variant="ghost"
  aria-label="Close the window"
>
  <X aria-hidden />
</Button>

Rule: if the button has no visible text, it must have an aria-label. Decorative icons carry aria-hidden.

Golden rules

  • Prefer native HTML (button, a, label) over ARIA.
  • Never duplicate a native role (no role="button" on a <button>).
  • Only one <main> tag per page.
  • Continuous heading order: h1 → h2 → h3, with no skips.

Forms

A label for every field, error messages linked to it, and aria-invalid handling.

Used only to send you the report.

Name is required.

Avoid
<Input placeholder="Email" />
Do
<Label htmlFor="email">Email</Label>
<Input
  id="email"
  type="email"
  autoComplete="email"
  aria-describedby="email-help"
  aria-invalid={hasError}
/>
<p id="email-help" className="text-xs text-muted-foreground">
  Used only to send you the report.
</p>

A placeholder is not a label. Always pair <Label htmlFor> with the field's id.

Reusable ARIA Patterns

Four ready-to-use components that encapsulate the most common WAI-ARIA patterns. Import from @/components/a11y-patterns.

AccessibleDialog

role="dialog"

A modal dialog with title, description, focus trap and focus return.

import { AccessibleDialog } from "@/components/a11y-patterns";

<AccessibleDialog
  trigger={<Button>Open the dialog</Button>}
  title="Confirm sending"
  description="The report will be sent to the address on file."
  footer={<Button>Send</Button>}
>
  <p>Dialog content…</p>
</AccessibleDialog>

AccessibleMenu

role="menu"

A context menu navigable by keyboard (arrows, Home/End, typeahead).

import { AccessibleMenu } from "@/components/a11y-patterns";

<AccessibleMenu
  label="Row actions"
  trigger={
    <Button variant="outline" size="icon" aria-label="Open the menu">
      <MoreHorizontal className="size-4" aria-hidden />
    </Button>
  }
  items={[
    { type: "label", label: "Actions" },
    { label: "Duplicate", icon: <Copy className="size-4" />, onSelect: () => {} },
    { type: "separator" },
    { label: "Delete", icon: <Trash2 className="size-4" />, onSelect: () => {} },
  ]}
/>

AccessibleCombobox

role="combobox"

A combined input + filterable listbox, with navigation and typeahead.

import { AccessibleCombobox } from "@/components/a11y-patterns";

<AccessibleCombobox
  label="Industry"
  options={sectors}
  value={value}
  onValueChange={setValue}
  placeholder="Choose an industry…"
/>

AccessibleAlert

role="alert / status"

An ARIA live region. status (polite) for info/success, alert (assertive) for errors.

Assessment saved
Your assessment has been saved to your drafts.
import { AccessibleAlert } from "@/components/a11y-patterns";

<AccessibleAlert tone="success" title="Assessment saved">
  Your assessment has been saved.
</AccessibleAlert>

<AccessibleAlert
  tone="error"
  title="Payment failed"
  onDismiss={() => setVisible(false)}
>
  Check your card, then try again.
</AccessibleAlert>

Images & Media

Every image has a relevant alt attribute — or an empty one if decorative.

Avoid
<img src="/logo.svg" />
<img src="/photo.jpg" alt="image" />
Do
{/* Decorative: empty alt + aria-hidden */}
<img src="/pattern.svg" alt="" aria-hidden />

{/* Informative: descriptive alt */}
<img
  src="/photo.jpg"
  alt="Viabilio team in a design workshop"
  width={800}
  height={600}
  loading="lazy"
  decoding="async"
/>

Always set width/height to avoid layout shifts (CLS). loading=lazy for offscreen images, decoding=async.

Animation & Motion

Every animation must be disableable via prefers-reduced-motion.

Avoid
<div className="animate-pulse transition-transform hover:scale-110" />
Do
<div className="hover-pop motion-reduce:hover:scale-100 motion-reduce:transition-none" />

/* Or via CSS: */
@media (prefers-reduced-motion: reduce) {
  .my-anim { animation: none; transition: none; }
}

Our .hover-lift, .hover-pop, .link-underline and [data-reveal] utilities already follow this rule.

Pre-Merge Checklist

To verify on every new component or route.

  • Full keyboard navigation (Tab, Enter, Escape).
  • Visible focus on all interactive elements.
  • Semantic tokens for text (no text-gray-*).
  • AA contrast verified (4.5:1 text / 3:1 UI).
  • Labels on all form fields.
  • aria-label on icon buttons.
  • Alt provided (or empty) on all images.
  • A single <main>, continuous heading structure.
  • min-h-dvh instead of min-h-screen.
  • prefers-reduced-motion respected by animations.
WCAG 2.1 AA
Radix / shadcn primitives
Design tokens only