n8n-io/n8n
Reviewed against Rams quality heuristics — accessibility, hierarchy, color, typography, spacing, components, motion, and UX.
30 files reviewed·May 13, 2026
Also worth noting
Verdict
n8n's UI components show strong design-token discipline but consistently skip the final accessibility pass — aria attributes, focus states, and screen-reader semantics are afterthoughts. The nested interactive element inside the balance badge is the sharpest edge: it silently breaks keyboard navigation for any user not using a mouse.
Files Rams reviewed
packages/frontend/editor-ui/src/app/components/AboutModal.vue
packages/frontend/editor-ui/src/app/components/ActivationModal.vue
packages/frontend/editor-ui/src/app/components/AiGatewaySelector.vue
packages/frontend/editor-ui/src/app/components/AiStarsIcon.vue
packages/frontend/editor-ui/src/app/components/AnimatedSpinner.vue
packages/frontend/editor-ui/src/app/components/Banner.vue
packages/frontend/editor-ui/src/app/components/BottomMenu.vue
packages/frontend/editor-ui/src/app/components/BreakpointsObserver.vue
packages/frontend/editor-ui/src/app/components/ChatEmbedModal.vue
packages/frontend/editor-ui/src/app/components/CollectionWorkflowCard.vue
packages/frontend/editor-ui/src/app/components/ConnectionTracker.vue
packages/frontend/editor-ui/src/app/components/CopyInput.vue
packages/frontend/editor-ui/src/app/components/DependencyPill.vue
packages/frontend/editor-ui/src/app/components/Draggable.vue
packages/frontend/editor-ui/src/app/components/DraggableTarget.vue
packages/frontend/editor-ui/src/app/components/DropArea/DropArea.vue
packages/frontend/editor-ui/src/app/components/DuplicateWorkflowDialog.vue
packages/frontend/editor-ui/src/app/components/DynamicModalLoader.vue
packages/frontend/editor-ui/src/app/components/Feedback.vue
packages/frontend/editor-ui/src/app/components/FocusSidebar.vue
packages/frontend/editor-ui/src/app/components/FreeAiCreditsCallout.vue
packages/frontend/editor-ui/src/app/components/FromAiParametersModal.vue
packages/frontend/editor-ui/src/app/components/ImportWorkflowUrlModal.vue
packages/frontend/editor-ui/src/app/components/IntersectionObserver.vue
packages/frontend/editor-ui/src/app/components/KeyboardShortcutTooltip.vue
packages/frontend/editor-ui/src/app/components/MainHeader/ActionsDropdownMenu.vue
packages/frontend/editor-ui/src/app/components/MainHeader/MainHeader.vue
packages/frontend/editor-ui/src/app/components/MainHeader/TabBar.vue
packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowPublishModal.vue
packages/frontend/editor-ui/src/app/components/MainSidebar.vue
Accessibility
Nested interactive element inside a button breaks keyboard access
The `N8nActionPill` is rendered inside the first `<button>` card with `@click="onBadgeClick"` and `event.stopPropagation()`. A focusable, clickable element nested inside a `<button>` is invalid HTML — interactive content is not permitted inside `<button>` per the spec, and some browsers collapse the inner hit target or fail to fire the inner click via keyboard.
Why it matters
Keyboard users pressing Enter/Space on the card activate the card, not the top-up flow. There is no keyboard path to the top-up modal unless the pill can be independently tabbed to — which depends entirely on how `N8nActionPill` renders, and nesting it inside `<button>` makes that unreliable across browsers.
Fix
Move the pill outside the card button so both are independently focusable interactive elements at the same DOM level.
<N8nActionPill
v-if="aiGatewayEnabled && balance !== undefined"
:clickable="!readonly"
:type="isBalanceDepleted ? 'danger' : 'default'"
size="small"
:text="
isBalanceDepleted
? i18n.baseText('aiGateway.wallet.noCredits')
: i18n.baseText('aiGateway.wallet.balanceRemaining', {
interpolate: { balance: `$${Number(balance).toFixed(2)}` },
})
"
:hover-text="!readonly ? i18n.baseText('aiGateway.toggle.topUp') : undefined"
@click="onBadgeClick"
/></button>
<N8nActionPill
v-if="aiGatewayEnabled && balance !== undefined"
:clickable="!readonly"
:type="isBalanceDepleted ? 'danger' : 'default'"
size="small"
:text="
isBalanceDepleted
? i18n.baseText('aiGateway.wallet.noCredits')
: i18n.baseText('aiGateway.wallet.balanceRemaining', {
interpolate: { balance: `$${Number(balance).toFixed(2)}` },
})
"
:hover-text="!readonly ? i18n.baseText('aiGateway.toggle.topUp') : undefined"
@click="onBadgeClick"
/>Spinning SVG announces nothing to screen readers
The `<svg>` has no `role`, no `aria-label`, and no `<title>` element. Screen readers will either skip it silently or announce the raw path data, giving users no indication that loading is in progress.
Why it matters
Users on assistive tech receive no feedback that the application is working, which makes the loading state invisible and breaks WCAG 4.1.2.
Fix
Add role="img" and aria-label to every standalone SVG that communicates state, or use aria-hidden if the spinner is always accompanied by a labelled parent.
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Loading">Decorative SVG lacks aria-hidden, polluting screen reader output
The `<svg>` element has no `aria-hidden="true"` and no accessible label. As a purely decorative icon, it will be announced by screen readers as an unlabeled graphic, adding noise to the accessibility tree.
Why it matters
Screen readers announce unlabeled SVGs as 'image' or read the raw path ID ('NodeIcon', 'Union'), which is meaningless to assistive tech users and compounds onboarding friction wherever this icon appears.
Fix
Decorative icons must carry aria-hidden="true" so assistive tech skips them entirely.
<svg
:width="sizes[size]"
:height="sizes[size]"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
><svg
:width="sizes[size]"
:height="sizes[size]"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
>Card buttons have no focus style in the disabled state — readonly looks active
The `.card` rule applies `opacity: 0.85` on `:disabled` — a subtle dimming that barely signals the unclickable state. The disabled card still renders with the same border and cursor style as the active one until the `cursor: not-allowed` kicks in. On a keyboard pass, a readonly card can still receive focus (disabled buttons are not focusable, but if `readonly` is used without the `disabled` attribute, the element remains focusable with no indication it is inert).
Why it matters
If `readonly` prop is true but `disabled` is not set on the button, keyboard users can focus and activate it. If `disabled` is always set when `readonly` is true, the opacity-only treatment still makes it unclear to sighted users that neither card is interactive.
Fix
Map the `readonly` prop to the native `disabled` attribute on both buttons, and raise disabled opacity to the standard `opacity-50` so the state reads as clearly inactive.
&:disabled {
cursor: not-allowed;
opacity: 0.85;
}&:disabled {
cursor: not-allowed;
opacity: 0.5;
pointer-events: none;
}Depleted balance indicated by color alone — no icon or text prefix
When `isBalanceDepleted` is true, the `N8nActionPill` switches to `type="danger"` (a red color treatment) and shows the text from `aiGateway.wallet.noCredits`. The only structural signal that something is wrong is the danger color — there is no icon (warning triangle, exclamation) and no prefix like "!" or "0 credits" to differentiate it from a normal balance reading for users who cannot perceive color.
Why it matters
A colorblind user sees two pills that differ only in their label text. If the i18n string for `noCredits` reads as a neutral noun phrase rather than an explicit warning, the urgency is invisible without the red color. Adding an icon to the danger state makes the status unambiguous.
Fix
Pair color-based status changes with a non-color indicator — an icon or explicit prefix — so the state is communicated through at least two channels.
:type="isBalanceDepleted ? 'danger' : 'default'":type="isBalanceDepleted ? 'danger' : 'default'"
:icon="isBalanceDepleted ? 'warning' : undefined""mini" and "small" sizes render at 8–10px, far below 44px touch target
The `mini` (8px) and `small` (10px) size variants render the SVG at dimensions that are functionally invisible as tap targets. If `AiStarsIcon` is ever used as the sole child of a button or interactive element, the hit area collapses to nearly nothing.
Why it matters
An 8px interactive area is unusable on touch devices and fails WCAG 2.5.5. Even as a decorative element, rendering at 8px means the icon is effectively invisible at normal viewing distances — it provides no visual signal.
Fix
Enforce a minimum rendered size of 12px for decorative icons and ensure any interactive wrapper carrying this icon meets the 44px touch target minimum independently.
const sizes = {
mini: 8,
small: 10,
medium: 12,
large: 16,
};const sizes = {
mini: 12,
small: 14,
medium: 16,
large: 20,
};Hierarchy
Typography
Color
Spacing
Components
Motion
UX
Working well
- ✓The radiogroup pattern is implemented correctly — `role="radiogroup"` on the container, `role="radio"` and `aria-checked` on each `<button>`, and a labelled group via `:aria-label`. This is the right semantic model for a mutually exclusive choice, and it means screen readers will announce the selection state without any extra ARIA scaffolding.
- ✓The `withDefaults` pattern with a typed union prop (`'mini' | 'small' | 'medium' | 'large'`) is clean API design: callsites get autocomplete, typos fail at compile time, and the default of `'medium'` means most usages need zero configuration. This is the correct way to expose a scale-based size prop in Vue 3.
- ✓Using `var(--color--primary)` for both the radioOuter border and the inner dot when selected means the selected state is tied to the design token, not a hardcoded hex. If the brand primary changes, the radio indicator updates automatically — this is the correct way to build stateful color.
- ✓Using `stroke="currentColor"` throughout is exactly right — the spinner inherits its color from whatever text color the parent sets, so it works in dark mode, on colored backgrounds, and inside buttons without a single hardcoded value. This is the correct pattern for icon-class SVGs.
Score your own repo with Rams.
Free on public repos. Reviews run on every pull request and post inline fix suggestions.
Review my public repoFree