Hi everyone,
Frappe UI has grown into an incredible, modern, Tailwind-based component library. However, as we build increasingly complex, JavaScript-heavy components (like Comboboxes, Dropdowns, and Dialogs), we sometimes inherently break native HTML accessibility unless we explicitly polyfill with ARIA attributes and keyboard event listeners.
To ensure applications built with Frappe UI are usable by everyone—including users relying on screen readers and keyboard navigation—I recently conducted a detailed accessibility audit of the repository against WCAG 2.1 AA standards .
I have mapped out exactly what needs to be fixed and have already opened tracking issues on GitHub for each major category. I’d love to hear your thoughts, gather feedback, and see who might be interested in collaborating on these improvements!
The Audit: Tracked Issues & What We Need
The audit highlights targeted improvements across Form Inputs, Focus Management, Keyboard Navigation, ARIA relationships, and Dynamic Live Regions. Here is the breakdown of the newly created issues where we need help:
1. Form Controls & Inputs
opened 04:49PM - 08 May 26 UTC
### Bug Report: [A11y] Standardize Form Controls & Inputs (WCAG 1.3.1, 3.3.1, 3.… 3.2, 4.1.2)
### Description
Currently, the custom form components (`TextInput`, `Textarea`, `Checkbox`, `Switch`, `Select`, `Password`, `FormControl`, `InputLabeling`) lack strict native HTML accessibility bindings. This causes issues for screen reader users when navigating forms or encountering validation errors, failing to meet WCAG 2.1 AA standards for programmatic relationships and error identification.
### Steps to Reproduce
1. Render a form using `FormControl` or `InputLabeling` combined with a custom input component (e.g., `TextInput`).
2. Focus on the input using a screen reader. Note that if an explicit ID is not provided, the visual label is not programmatically associated with the input.
3. Trigger a validation error. Note that the screen reader does not announce the error text because `aria-invalid` and `aria-errormessage` bindings are missing.
4. Focus on a `Switch` component. Note that it does not announce its state to screen readers or toggle when pressing the `Space` key.
5. Check a `RequiredIndicator`. Note that the visual asterisk `*` is read aloud unnecessarily by screen readers.
### Expected vs. Actual
- **Expected**: All form inputs should auto-generate unique IDs linked to their labels. Validation errors must be programmatically linked to inputs via ARIA attributes. Custom switches must act like native checkboxes (`role="switch"` and keyboard navigable). Visual required indicators should be hidden from assistive technologies.
- **Actual**: Form inputs lack strict ID bindings by default. Errors are displayed visually but are completely disconnected programmatically. Switches lack correct ARIA semantics and keybindings. Asterisks cause screen-reader clutter.
### Suggested Fix
Implement an automated ID generation composable, dynamically bind ARIA attributes for error states, update Switch semantics, and apply `aria-hidden` to required indicators.
```typescript
// [REQ-1.1] Auto-generate IDs (src/composables/useId.ts)
import { ref } from 'vue';
let idCounter = 0;
export function useId(prefix = 'frappe-input') {
idCounter++;
return ref(`${prefix}-${idCounter}`);
}
```
```html
<template>
<div class="form-control">
<label :for="inputId">{{ label }}</label>
<slot v-bind="{
id: inputId,
'aria-invalid': !!errorMessage ? 'true' : 'false',
'aria-errormessage': errorMessage ? errorId : null,
'aria-describedby': errorMessage ? errorId : null
}" />
<p v-if="errorMessage" :id="errorId" role="alert" class="mt-1 text-sm text-red-600">
{{ errorMessage }}
</p>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useId } from '@/composables/useId';
const props = defineProps({
id: { type: String, default: null },
label: { type: String, required: true },
errorMessage: { type: String, default: '' }
});
const inputId = props.id || useId().value;
const errorId = computed(() => `${inputId}-error`);
</script>
```
```html
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
:aria-label="ariaLabel"
@click="toggle"
@keydown.space.prevent="toggle"
class="relative inline-flex h-6 w-11 items-center rounded-full"
:class="modelValue ? 'bg-blue-600' : 'bg-gray-200'"
>
<span class="sr-only" v-if="!ariaLabel">{{ label }}</span>
<span :class="modelValue ? 'translate-x-6' : 'translate-x-1'" class="inline-block h-4 w-4 transform rounded-full bg-white transition" />
</button>
</template>
```
```html
<template>
<span aria-hidden="true" class="text-red-500 ml-1" title="Required">*</span>
</template>
```
(Applies to: TextInput, Textarea, Checkbox, Switch, Select, Password, FormControl, InputLabeling)
The Gap: Visual labels and inputs sometimes lack strict association via for and id attributes. Error messages aren’t automatically announced by screen readers. Custom controls like Switch lack native semantic meaning.
The Fix: Auto-generate unique IDs in useId.ts and bind them strictly. Dynamically apply aria-invalid="true" and aria-errormessage on validation failures. Add proper role="switch" semantics.
2. Interactive Overlays & Modals
opened 04:58PM - 08 May 26 UTC
### Bug Report: [A11y] Interactive Overlays, Modals, and Tooltips (WCAG 2.1.1, 2… .1.2, 2.4.3, 1.4.13)
### Description
Interactive overlays (`Dialog`, `Popover`, `Tooltip`, `CommandPalette`) currently fail to trap keyboard focus correctly or respect tooltip persistence rules. This violates WCAG requirements for keyboard navigation (2.1.1, 2.1.2, 2.4.3) and content on hover/focus (1.4.13). Specifically:
1. **Focus Management**: Focus escapes the active `Dialog` and moves to background elements, confusing screen reader and keyboard-only users.
2. **Missing ARIA Roles**: Modals lack `role="dialog"`, `aria-modal="true"`, and proper labeling bindings (`aria-labelledby` / `aria-describedby`).
3. **Keyboard Dismissal**: Overlays cannot be consistently dismissed using the `Escape` key.
4. **Tooltip Persistence**: Tooltips disappear when a user attempts to hover over the tooltip text itself, failing the "Hoverable" requirement of WCAG 1.4.13.
### Steps to Reproduce
1. Open a `Dialog` or `CommandPalette` component and press the `Tab` key repeatedly. Note that the focus eventually leaves the modal and targets background page elements.
2. With an overlay or tooltip open, press the `Escape` key. Note that the component fails to immediately close.
3. Trigger a `Tooltip` via mouse hover, then move the pointer over the tooltip bubble. Note that the tooltip instantly disappears instead of persisting.
4. Inspect the DOM element of an open `Dialog`. Note the missing `role="dialog"` and `aria-modal="true"` attributes.
### Expected vs. Actual
- **Expected**: Modals must trap keyboard focus while open and restore it to the trigger element upon closing. All overlays (Dialogs, Popovers, Tooltips) must be dismissible via the `Escape` key. Tooltips must be hoverable and persistent. Modals must possess correct ARIA semantics.
- **Actual**: Focus leaks out of modals, overlays resist keyboard dismissal, tooltips vanish upon hover, and foundational structural ARIA attributes are absent.
### Suggested Fix
Integrate `@vueuse/core` (specifically `useFocusTrap` and `onKeyStroke`) to manage focus boundaries and keyboard events safely. Conditionally bind native ARIA attributes to the root elements of these components. Update tooltip mouse event logic to allow a delay or cancel the hide event when the tooltip itself is hovered.
```typescript
// Suggested Code Changes
// 1. Dialog Focus Management & ARIA (Dialog.vue)
/*
<template>
<div
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="descriptionId"
ref="dialogRef"
class="dialog-container"
>
<slot />
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import { useFocusTrap, onKeyStroke } from '@vueuse/core';
const props = defineProps({ isOpen: Boolean, titleId: String, descriptionId: String });
const emit = defineEmits(['close']);
const dialogRef = ref(null);
// [REQ-2.1] Dialog Focus Management
const { activate, deactivate } = useFocusTrap(dialogRef, { immediate: false });
watch(() => props.isOpen, (newVal) => {
if (newVal) activate();
else deactivate(); // Automatically restores focus to previous active element
});
// [REQ-2.3] Escape Key Dismissal
onKeyStroke('Escape', (e) => {
if (props.isOpen) {
e.preventDefault();
emit('close');
}
});
</script>
*/
// 2. Tooltip Hover/Focus Rules (Tooltip.vue)
/*
<template>
<div
@mouseenter="showTooltip"
@mouseleave="startHideTimer"
@focus="showTooltip"
@blur="hideTooltip"
@keydown.escape.prevent="hideTooltip"
:aria-describedby="isOpen ? tooltipId : null"
>
<slot name="trigger" />
</div>
<div
v-if="isOpen"
:id="tooltipId"
role="tooltip"
@mouseenter="cancelHideTimer"
@mouseleave="startHideTimer"
class="tooltip-bubble"
>
<slot />
</div>
</template>
<script setup>
import { ref } from 'vue';
const isOpen = ref(false);
let hideTimeout = null;
const showTooltip = () => {
cancelHideTimer();
isOpen.value = true;
};
const hideTooltip = () => {
isOpen.value = false;
};
// Allows moving pointer over the tooltip without it disappearing (WCAG 1.4.13)
const startHideTimer = () => {
hideTimeout = setTimeout(() => {
hideTooltip();
}, 150);
};
const cancelHideTimer = () => {
if (hideTimeout) clearTimeout(hideTimeout);
};
</script>
*/
(Applies to: Dialog, Popover, Tooltip, CommandPalette)
The Gap: Keyboard focus isn’t always trapped inside open modals, and closing them doesn’t consistently return focus to the trigger. Tooltips don’t perfectly align with WCAG hover/persistence rules.
The Fix: Implement strict focus trapping and focus restoration for Dialog. Ensure all overlays are dismissible via the Escape key. Update Tooltip to be persistent and hoverable to meet WCAG 1.4.13.
3. Complex Selection & Navigation
opened 05:04PM - 08 May 26 UTC
### Bug Report: [A11y] Complex Selection & Navigation Patterns (WCAG 4.1.2, 2.1.… 1)
### Description
Complex UI components like `Combobox`, `MultiSelect`, `Dropdown`, `Tabs`, `Sidebar`, and `Tree` require robust keyboard navigation and specific ARIA role hierarchies to function correctly with assistive technologies. Currently, these components lack the strict W3C specified ARIA patterns and advanced keyboard event handling (like roving tabindex), failing WCAG 4.1.2 (Name, Role, Value) and 2.1.1 (Keyboard).
### Steps to Reproduce
1. Focus on a `Combobox` or `MultiSelect` input and attempt to navigate options using `Up`/`Down` arrows with a screen reader running. Note the absence of `aria-activedescendant` announcements.
2. Open a `Dropdown` menu and press `ArrowDown`. Note if focus moves natively between `menuitem` elements or if it requires excessive `Tab` presses.
3. Focus on a `Tabs` list. Press `Tab` and note that it traverses every single tab rather than exiting the tablist. Press `ArrowLeft`/`ArrowRight` and note the lack of native lateral navigation.
4. Inspect the DOM of a `Tree` component. Note the absence of `role="tree"`, `role="treeitem"`, and dynamic `aria-expanded` attributes on parent nodes.
### Expected vs. Actual
- **Expected**: `Combobox` must announce active items via `aria-activedescendant`. `Dropdown` must use `role="menu"` and allow arrow-key navigation. `Tabs` must implement a roving `tabindex` (arrows navigate tabs, `Tab` key exits the list). `Tree` must map to standard structural ARIA roles.
- **Actual**: Complex components rely on basic click/focus semantics, lacking the required composite widget ARIA roles and specialized keyboard event interception.
### Suggested Fix
Implement the W3C composite UI patterns across the designated components.
- [ ] **[REQ-3.1] Combobox ARIA Pattern:** Update `Combobox.vue` and `MultiSelect.vue` to strictly follow the W3C ARIA Combobox pattern. Input needs `role="combobox"`, `aria-expanded`, `aria-controls`. Menu needs `role="listbox"`, items need `role="option"`. Implement `aria-activedescendant` for Up/Down arrow key navigation.
- [ ] **[REQ-3.2] Dropdown Menu Navigation:** `Dropdown.vue` must implement `role="menu"` and `role="menuitem"`. Intercept `ArrowDown` and `ArrowUp` keys to move focus directly between menu items.
- [ ] **[REQ-3.3] Tabs Keyboard Roving:** In `Tabs.vue`, implement roving tabindex. The active tab gets `tabindex="0"`, inactive tabs get `tabindex="-1"`. Users should navigate tabs using `ArrowLeft`/`ArrowRight`, while the `Tab` key should move focus out of the tablist entirely.
- [ ] **[REQ-3.4] Tree Accessibility:** `Tree.vue` must implement `role="tree"`, `role="treeitem"`, `aria-expanded` (for nested nodes), and `aria-level`.
```typescript
// Suggested Code Changes
// 1. Tabs Roving Tabindex (Tabs.vue)
/*
<template>
<div role="tablist" aria-label="Tabs Content" @keydown="handleKeydown">
<button
v-for="(tab, index) in tabs"
:key="tab.id"
role="tab"
:aria-selected="activeIndex === index"
:aria-controls="`panel-${tab.id}`"
:id="`tab-${tab.id}`"
:tabindex="activeIndex === index ? 0 : -1"
@click="activeIndex = index"
ref="tabRefs"
>
{{ tab.label }}
</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({ tabs: Array });
const activeIndex = ref(0);
const tabRefs = ref([]);
const handleKeydown = (e) => {
let newIndex = activeIndex.value;
if (e.key === 'ArrowRight') {
newIndex = (activeIndex.value + 1) % props.tabs.length;
} else if (e.key === 'ArrowLeft') {
newIndex = (activeIndex.value - 1 + props.tabs.length) % props.tabs.length;
}
if (newIndex !== activeIndex.value) {
activeIndex.value = newIndex;
tabRefs.value[newIndex]?.focus();
}
};
</script>
*/
// 2. Combobox ARIA Binding (Combobox.vue snippet)
/*
<template>
<div class="combobox-wrapper">
<input
role="combobox"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-activedescendant="isOpen && activeOptionId ? activeOptionId : null"
@keydown.down.prevent="navigateOptions(1)"
@keydown.up.prevent="navigateOptions(-1)"
/>
<ul
v-show="isOpen"
:id="listboxId"
role="listbox"
>
<li
v-for="(option, index) in options"
:key="option.id"
:id="`option-${option.id}`"
role="option"
:aria-selected="selectedOption === option"
>
{{ option.label }}
</li>
</ul>
</div>
</template>
*/
(Applies to: Combobox, MultiSelect, Dropdown, Tabs, Sidebar, Tree)
The Gap: These are notoriously tricky. They require rigorous Arrow Key navigation and explicit ARIA parent-child relationships that are currently missing or incomplete.
The Fix: Strictly implement the W3C ARIA Combobox pattern (role="combobox", role="listbox", aria-activedescendant) for Combobox and MultiSelect. Add ArrowUp/ArrowDown navigation to Dropdowns. Implement roving tabindex for Tabs.
4. Feedback & Status Messages
opened 05:11PM - 08 May 26 UTC
### Bug Report: [A11y] Feedback, Toasts & Status Messages (WCAG 4.1.3, 1.1.1)
#… ## Description
Dynamic status updates across components such as `Toast`, `Alert`, `Progress`, and `CircularProgressBar` are visually apparent but lack the programmatic hooks necessary to alert screen reader users that a change has occurred on the screen. This violates WCAG 4.1.3 (Status Messages) and 1.1.1 (Non-text Content), as assistive technologies are not notified of critical feedback, warnings, or progress updates.
### Steps to Reproduce
1. Trigger a `Toast` notification (e.g., a success or error message) while running a screen reader. Note that the message is not announced automatically.
2. Render an `Alert` component with an error state and inspect its DOM. Note the absence of `role="alert"`.
3. Inspect a `Progress` or `CircularProgressBar` component while it is updating. Note the lack of `role="progressbar"` and the missing `aria-valuenow` attributes mapping to the current visual percentage.
### Expected vs. Actual
- **Expected**: `Toast` containers should utilize `aria-live` regions to announce new messages natively. `Alert` components should dynamically assign `role="alert"` or `role="status"`. Progress indicators should expose their current numerical value via standard ARIA progress bar semantics.
- **Actual**: Components rely entirely on visual layout and color changes to convey status, leaving keyboard-only and screen reader users completely unaware of dynamic system feedback.
### Suggested Fix
Update the underlying wrapper elements of these components to include standard W3C ARIA status and progress attributes.
- [ ] **[REQ-4.1] Toast Live Regions:** Update `ToastProvider.vue` to render toasts inside a container with `aria-live="polite"` (for standard notifications) or `aria-live="assertive"` (for critical errors).
- [ ] **[REQ-4.2] Alert Roles:** `Alert.vue` should automatically map to `role="alert"` (for errors/warnings) or `role="status"` (for info/success).
- [ ] **[REQ-4.3] Progress Value Semantics:** `Progress.vue` and `CircularProgressBar.vue` must implement `role="progressbar"`, `aria-valuenow`, `aria-valuemin="0"`, and `aria-valuemax="100"`.
```typescript
// Suggested Code Changes
// 1. Toast Live Regions (ToastProvider.vue wrapper)
/*
<template>
<div
aria-live="polite"
aria-atomic="true"
class="toast-container fixed bottom-0 right-0 p-4"
>
<Toast
v-for="toast in toasts"
:key="toast.id"
v-bind="toast"
:aria-live="toast.variant === 'error' ? 'assertive' : 'polite'"
/>
</div>
</template>
*/
// 2. Alert Roles (Alert.vue root element)
/*
<template>
<div
:role="['error', 'warning'].includes(variant) ? 'alert' : 'status'"
class="alert-wrapper p-4 rounded-md"
:class="variantClasses"
>
<slot />
</div>
</template>
*/
// 3. Progress Value Semantics (Progress.vue snippet)
/*
<template>
<div
role="progressbar"
:aria-valuenow="percent"
aria-valuemin="0"
aria-valuemax="100"
class="w-full bg-gray-200 rounded-full h-2.5"
>
<div
class="bg-blue-600 h-2.5 rounded-full"
:style="{ width: `${percent}%` }"
></div>
</div>
</template>
<script setup>
defineProps({
percent: {
type: Number,
required: true,
validator: (val) => val >= 0 && val <= 100
}
});
</script>
*/
(Applies to: Toast, Alert, Progress, CircularProgressBar)
The Gap: Toasts visually appear but aren’t programmatically announced to screen readers. Progress bars lack value semantics.
The Fix: Wrap toasts in an aria-live="polite" or aria-live="assertive" region. Apply role="alert" or role="status" to Alerts. Add role="progressbar" to progress indicators.
5. Global Design System & Utilities
opened 05:15PM - 08 May 26 UTC
### Bug Report: [A11y] Global Design System: Focus, Contrast, and Icons (WCAG 1.… 4.3, 2.4.7, 2.3.3)
### Description
There are systemic accessibility concerns within our core design tokens and utility configurations that impact the entire UI library. Specifically, focus visibility is suppressed via `focus:outline-none`, color contrast ratios for secondary text fail to meet the 4.5:1 minimum, icon semantics are missing, and interactive components do not respect OS-level reduced motion preferences. These issues represent global violations of WCAG 1.4.3 (Contrast), 2.4.7 (Focus Visible), and 2.3.3 (Animation from Interactions).
### Steps to Reproduce
1. Render an icon-only `Button` without an `aria-label` or slot text and navigate to it using a screen reader. Note the lack of context provided.
2. Navigate interactively through various components using the `Tab` key. Observe that several interactive elements with `focus:outline-none` lack a highly visible focus indicator.
3. Use a contrast checker tool on placeholder, helper, or disabled text styles defined in `tailwind/colors.json`. Note that ratios fall below 4.5:1 against default backgrounds.
4. Inspect `FeatherIcon` or Lucide `<svg>` elements in the DOM. Note the absence of `aria-hidden="true"`.
5. Toggle the OS-level "Reduce Motion" preference, trigger a `Dialog` or `Toast`, and note that animations still play.
### Expected vs. Actual
- **Expected**: Icon buttons must provide accessible names. Focus indicators must be explicitly defined (e.g., using `focus-visible`). Text contrast must meet a minimum of 4.5:1. Decorative icons must be hidden from screen readers. Animations must respect `prefers-reduced-motion` queries.
- **Actual**: Icon buttons fail silently without labels. Focus rings are suppressed. Contrast ratios fall below WCAG minimums. Screen readers announce decorative SVGs as unlabeled graphics. Animations play regardless of user system preferences.
### Suggested Fix
Update core tokens and utilities. Add developer warnings for missing accessible names on buttons, replace `focus:outline-none` with `focus-visible:ring` utilities, audit color tokens, add `aria-hidden` to icons, and wrap animations in Tailwind's `motion-safe` variant.
- [ ] **[REQ-5.1] Enforce Accessible Names on Buttons:** Update `Button.vue` to throw a console warning in development mode if the button contains an icon but lacks slot text and an `aria-label`.
- [ ] **[REQ-5.2] Focus Visible Rings:** Globally audit CSS and component configurations to enforce `focus-visible` utility classes.
- [ ] **[REQ-5.3] Color Contrast Audit:** Check `tailwind/colors.json` and `tailwind/colorPalette.js` to ensure text variants meet a 4.5:1 contrast ratio.
- [ ] **[REQ-5.4] Icon Semantics:** Update `FeatherIcon.vue` and Lucide instances to auto-apply `aria-hidden="true"` to `<svg>` elements.
- [ ] **[REQ-5.5] Reduced Motion:** Wrap transition/animation utilities in Tailwind's `motion-safe:` or `motion-reduce:` variants globally.
```typescript
// Suggested Code Changes
// 1. Enforce Accessible Names on Buttons (Button.vue)
/*
<script setup>
import { onMounted, useSlots } from 'vue';
const props = defineProps({
icon: String,
ariaLabel: String
});
const slots = useSlots();
onMounted(() => {
if (import.meta.env.DEV) {
const hasText = !!slots.default;
const hasIcon = !!props.icon || !!slots.icon;
if (hasIcon && !hasText && !props.ariaLabel) {
console.warn(
'[Frappe UI A11y]: Icon-only button rendered without an accessible name. ' +
'Please provide an `aria-label` to ensure screen reader support.'
);
}
}
});
</script>
*/
// 2. Icon Semantics (FeatherIcon.vue root element)
/*
<template>
<svg
xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)"
aria-hidden="true"
focusable="false"
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<slot />
</svg>
</template>
*/
// 3. Reduced Motion & Focus Rings (Example Tailwind Utility Update)
/*
// Replace:
// class="transition-all duration-200 focus:outline-none"
// With:
// class="motion-safe:transition-all motion-safe:duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
*/
(Applies to: Icons, Button, Tailwind Configuration)
The Gap: Icon-only buttons often lack accessible names. Focus outlines are sometimes disabled. Some secondary text colors fail the 4.5:1 contrast ratio.
The Fix: Globally implement focus-visible rings for all interactive elements—no more invisible tabbing! Enforce aria-label on icon-only buttons. Apply aria-hidden="true" to SVG icons.
6. Automated Testing (CI/CD)
To ensure we don’t regress as the library grows, we need tooling.
The Fix: Integrate cypress-axe into our component testing (*.cy.ts) and consider eslint-plugin-vuejs-accessibility to catch missing labels and roles directly in our IDEs.
Let’s Build Together!
Tackling this all at once is a massive undertaking, which is why it is split into 6 distinct issues.
Are there specific components you feel should be prioritized? Jump into the GitHub issues linked above, grab one that you’re passionate about, and let’s start hacking!
Let’s make Frappe UI the most accessible Vue component library out there!