Rams MCP · The full engine, now in your coding agent
heroui-inc on GitHub

heroui-inc/heroui

Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.

30 files reviewed·July 9, 2026

Elevated

Accessibility needs attention.

6issues
Critical & Serious

Top fix

Add an accessible name to the icon-only theme builder link

See the fix

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

89/100

Accessibility

1 critical4 serious
Accessibility·apps/docs/src/app/[lang]/(home)/components/demo-showcase.tsx:233Critical

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>
Accessibility·apps/docs/src/app/docs/native-showcase/components/[[...slug]]/page.tsx:46Serious

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>
  );
};
Accessibility·apps/docs/src/app/[lang]/(home)/components/release-badges.tsx:101Serious

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)}
>
Accessibility·apps/docs/src/app/[lang]/(home)/components/pro-badge.tsx:5Serious

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}
>
Accessibility·apps/docs/src/app/[lang]/(home)/components/pro-title.tsx:5Serious

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}
>
98/100

Color

1 serious
Color·apps/docs/src/app/[lang]/(home)/components/pro-banner.tsx:275Serious

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

No issues found

Spacing

No issues found

Components

No issues found

Motion

No issues found

UX

No issues found

Craft

No issues found

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.