heroui-inc/heroui
Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.
30 files reviewed·July 9, 2026
More findings
Verdict
Solid engineering keeps getting undercut by accessibility afterthoughts: icon buttons, logos, and badges routinely ship with no accessible name at all. The real risk is that these gaps cluster around the exact touchpoints, navigation and identity, that every screen reader user hits first.
Files Rams reviewed
apps/docs/src/app/docs/native-showcase/components/[[...slug]]/page.tsx
apps/docs/src/app/[lang]/(home)/components/demo-showcase.tsx
apps/docs/src/app/[lang]/(home)/components/pro-badge.tsx
apps/docs/src/app/[lang]/(home)/components/pro-banner.tsx
apps/docs/src/app/[lang]/(home)/components/pro-title.tsx
apps/docs/src/app/[lang]/(home)/components/release-badges.tsx
apps/docs/src/app/[lang]/themes/components/accent-color-selector.tsx
apps/docs/src/app/[lang]/themes/components/bottom-sheet.tsx
apps/docs/src/app/[lang]/themes/components/builder-header.tsx
apps/docs/src/app/[lang]/themes/components/chroma-slider.tsx
apps/docs/src/app/[lang]/themes/components/custom-fonts.tsx
apps/docs/src/app/[lang]/themes/components/font-family-popover.tsx
apps/docs/src/app/[lang]/themes/components/lockable-label.tsx
apps/docs/src/app/[lang]/themes/components/mobile-footer.tsx
apps/docs/src/app/[lang]/themes/components/onboarding.tsx
apps/docs/src/app/[lang]/themes/components/preview-container.tsx
apps/docs/src/app/[lang]/themes/components/radius-popover.tsx
apps/docs/src/app/[lang]/themes/components/reset-button.tsx
apps/docs/src/app/[lang]/themes/components/shuffle-button.tsx
apps/docs/src/app/[lang]/themes/components/suggested-fonts.tsx
apps/docs/src/app/[lang]/themes/components/switch-mode.tsx
apps/docs/src/app/[lang]/themes/components/theme-builder-content.tsx
apps/docs/src/app/[lang]/themes/components/theme-code-panel.tsx
apps/docs/src/app/[lang]/themes/components/theme-input.tsx
apps/docs/src/app/[lang]/themes/components/theme-popover.tsx
apps/docs/src/app/[lang]/themes/components/themes-list.tsx
apps/docs/src/app/[lang]/(home)/layout.tsx
apps/docs/src/app/[lang]/(home)/page.tsx
apps/docs/src/app/[lang]/blog/[slug]/page.tsx
apps/docs/src/app/[lang]/blog/page.tsx
Accessibility
Icon-only theme builder link has no accessible name for screen readers
The `LinkRoot` wrapped in `Tooltip.Trigger` renders only `<Palette className="size-4" />` as its content, with no `aria-label`. The tooltip text ("themeBuilder") only shows on hover/focus as a description, it does not give the link a name.
Why it matters
Screen reader users hear "link" with no name and have no idea it opens the theme builder, so a working navigation path is effectively invisible to them.
Fix
Give icon-only interactive elements an explicit aria-label naming the action.
<LinkRoot
href={themeBuilderHref}
className={buttonVariants({
className: "text-muted",
isIconOnly: true,
size: "sm",
variant: "ghost",
})}
>
<Palette className="size-4" />
</LinkRoot><LinkRoot
href={themeBuilderHref}
aria-label={demo.themeBuilder}
className={buttonVariants({
className: "text-muted",
isIconOnly: true,
size: "sm",
variant: "ghost",
})}
>
<Palette className="size-4" />
</LinkRoot>Fallback page has no visible content differentiating it from a broken link
`NativeShowcaseComponentsFallbackPage` renders only `<DownloadAppBanner />` with no heading, no landmark, and no fallback messaging in this file. Since this route serves any unmatched slug at runtime (`dynamicParams = true`), a user who lands here after a failed deep link sees whatever `DownloadAppBanner` renders with zero context about why they were routed to a generic page.
Why it matters
If `DownloadAppBanner` doesn't itself declare a heading or landmark, screen reader users and users on slow connections have no way to confirm the page loaded correctly or understand why they're seeing a generic banner instead of the component they clicked through for.
Fix
Confirm DownloadAppBanner renders a heading (h1) and a main landmark so the fallback state is self-explanatory without depending on this file's absent structure.
const NativeShowcaseComponentsFallbackPage: FC = () => {
return <DownloadAppBanner />;
};const NativeShowcaseComponentsFallbackPage: FC = () => {
return (
<main>
<DownloadAppBanner />
</main>
);
};Auto-rotating badge carousel gives no way to stop or hear updates
The carousel `<div>` cycles badges every `intervalMs` (default 5000ms) via `setIndex`, pausing only on `onMouseEnter`/`onFocusCapture`. There is no `aria-live` region announcing the swapped label, and no persistent pause control, so: - Screen-reader users browsing without hovering or tabbing never learn content is changing. - Once focus/hover leaves, rotation resumes with no way to stop it permanently.
Why it matters
Content that updates automatically and repeats needs a way to pause it (WCAG 2.2.2). Screen-reader users relying on virtual cursor navigation get no announcement when the badge changes underneath them, and no user can stop the rotation short of keeping a pointer or focus parked on it indefinitely.
Fix
Mark the rotating region with aria-live="polite" so assistive tech announces each change, and treat hover/focus pause as a supplement, not the only stop mechanism.
<div
className="flex h-6 items-center justify-center"
onBlurCapture={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
><div
aria-live="polite"
aria-atomic="true"
className="flex h-6 items-center justify-center"
onBlurCapture={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>ProBadge icon has no aria-hidden, so screen readers announce noise
The root `<svg>` in `ProBadge` sets `fill`, `height`, `viewBox`, `width` but no `aria-hidden` or `role` default. Wherever this badge sits next to a visible "Pro" label, assistive tech will still try to announce the raw SVG (often read as "image" or nothing meaningful) alongside the text it's decorating.
Why it matters
Screen reader users hear a redundant or confusing announcement every time the badge renders next to its own label, adding noise to every Pro-tier callout across the docs site.
Fix
Default decorative icons to aria-hidden='true' and let call sites override it when the icon is the sole conveyor of meaning.
<svg
fill="none"
height={16}
viewBox="0 0 16 16"
width={16}
xmlns="http://www.w3.org/2000/svg"
{...props}
><svg
fill="none"
height={16}
viewBox="0 0 16 16"
width={16}
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
{...props}
>Logo SVG has no accessible name for screen reader users
The `ProTitle` component renders a logotype SVG (spelling "Pro") with no `role="img"`, no `<title>` element, and no `aria-label` passed through props. Since `props` is spread onto the `<svg>`, a consumer could pass `aria-label`, but the component itself supplies no default.
Why it matters
Screen reader users get no announcement for this logo mark at all, since SVGs without an accessible name are typically skipped entirely, unlike `<img>` without alt which at least falls back to the filename.
Fix
Give the SVG a default accessible name via role and aria-label, overridable through props.
<svg
fill="none"
height={46}
viewBox="0 0 125 46"
width={125}
xmlns="http://www.w3.org/2000/svg"
{...props}
><svg
fill="none"
height={46}
viewBox="0 0 125 46"
width={125}
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Pro"
{...props}
>Color
Hardcoded hex #4E75A5 fails contrast and bypasses tokens
Both countdown states in the banner hero use `text-[#4E75A5]`: - "Ends in ... d h m s" (live discount) - "Now available" (`heroLabel`, default state) This hex value sits outside the token set used everywhere else in the file (`text-foreground`, `text-muted`, `bg-accent`).
Why it matters
Against the pastel gradient background (~#E9E9FF to #CCE5F1) this color computes to roughly 3.7:1, which fails WCAG AA's 4.5:1 threshold for normal-size text (this is 12px/text-xs). It also can't be updated by a future theme change since it's not wired to any token.
Fix
Replace the hardcoded hex with an existing semantic token, darkened enough to clear 4.5:1 against the hero background.
<span className="relative text-xs text-[#4E75A5] tabular-nums"><span className="relative text-xs text-foreground/70 tabular-nums">Typography
Spacing
Components
Motion
UX
Craft
Working well
- Computing `--accent`, `--accent-foreground`, and `--focus` dynamically from the user's chosen swatch via `toOklch` and `calculateAccentForeground` is the right approach: instead of hardcoding a foreground color per swatch, the contrast is derived algorithmically from lightness/chroma/hue, so any future color added to the `colors` array automatically gets a readable foreground.
- The reduced-motion branch doesn't just shorten the animation, it swaps the entire mechanism: `setInterval` with a plain opacity fade instead of the spring-driven `progress` motion value. That's the right way to honor prefers-reduced-motion, because it removes the y-translation and spring math entirely rather than just setting duration to near-zero.
- The comments above `dynamic`, `dynamicParams`, and `metadata` each explain the *why* (Universal Links interception, noindex for a URL that's never meant to surface in search) rather than narrating the code. That's the right use of comments: they carry decisions a future maintainer can't infer just by reading the JSX.
- The loading overlay (`Spinner` inside an absolutely positioned div fading via `opacity-100`/`opacity-0`) sits on top of the iframe instead of swapping content in and out, so the demo pane never resizes or jumps while the iframe loads. That's the correct pattern for preventing layout shift during async loads.
Score your own repo with Rams.
Free on public repos. Rams reviews every pull request for accessibility, color, type, spacing, components, motion, UX, and craft, and posts inline fixes.