vercel on GitHub

vercel/turbo

Reviewed against Rams quality heuristics — accessibility, hierarchy, color, typography, spacing, components, motion, and UX.

30 files reviewed·May 13, 2026

Top fix

Add alt text and aria-hidden to ThemeImage's non-active image.

See the fix

Verdict

Starter templates ship accessibility bugs that get cloned into production — ThemeImage's dual-image pattern is the clearest example. The missing alt attribute and unmasked screen-reader duplicate are fixable in minutes but compound every time a new app forks this scaffold.

Files Rams reviewed

examples/with-docker/apps/web/src/app/page.tsx

apps/docs/app/[lang]/(home)/components/templates.tsx

apps/docs/app/[lang]/(extra)/[...slug]/page.tsx

apps/docs/app/[lang]/(home)/blog/[...slug]/page.tsx

apps/docs/app/[lang]/(home)/blog/page.tsx

apps/docs/app/[lang]/(home)/page.tsx

apps/docs/app/[lang]/(home)/showcase/page.tsx

apps/docs/app/[lang]/(openapi)/docs/openapi/[[...slug]]/page.tsx

apps/docs/app/[lang]/docs/[[...slug]]/page.tsx

apps/docs/app/[lang]/layout.tsx

examples/basic/apps/docs/app/page.tsx

examples/basic/apps/web/app/page.tsx

examples/kitchen-sink/apps/storefront/src/app/layout.tsx

examples/kitchen-sink/apps/storefront/src/app/page.tsx

examples/non-monorepo/app/page.tsx

examples/with-biome/apps/docs/app/page.tsx

examples/with-biome/apps/web/app/page.tsx

examples/with-changesets/apps/docs/src/app/layout.tsx

examples/with-changesets/apps/docs/src/app/page.tsx

examples/with-docker/apps/web/src/app/layout.tsx

examples/with-microfrontends/apps/docs/app/nested/page.tsx

examples/with-microfrontends/apps/docs/app/page.tsx

examples/with-microfrontends/apps/web/app/page.tsx

examples/with-nestjs/apps/web/app/page.tsx

examples/with-nextjs-elysia/apps/web/src/app/layout.tsx

examples/with-nextjs-elysia/apps/web/src/app/page.tsx

examples/with-npm/apps/docs/app/page.tsx

examples/with-npm/apps/web/app/page.tsx

examples/with-otel/apps/docs/app/page.tsx

examples/with-otel/apps/web/app/page.tsx

92/100

Accessibility

2 critical1 serious
Accessibility·examples/basic/apps/web/app/page.tsx:13Critical

ThemeImage renders two images — screen readers announce both

ThemeImage renders two <Image> elements — one for light, one for dark — using CSS classes to hide the inactive one visually. Neither has `aria-hidden` on the decorative duplicate, so screen readers announce both 'Turborepo logo' and 'Turborepo logo' in sequence.

Why it matters

Screen reader users hear the same image label twice, which is confusing and implies two distinct images exist. The redundant announcement breaks the read sequence at the top of the page.

Fix

The inactive theme image is purely decorative at runtime — mark it aria-hidden so only the active image is announced.

return (
  <>
    <Image {...rest} src={srcLight} className="imgLight" />
    <Image {...rest} src={srcDark} className="imgDark" />
  </>
);
return (
  <>
    <Image {...rest} src={srcLight} className="imgLight" aria-hidden="true" />
    <Image {...rest} src={srcDark} className="imgDark" />
  </>
);
Accessibility·examples/with-biome/apps/docs/app/page.tsx:13Critical

ThemeImage renders one Image with no alt attribute

The `ThemeImage` component spreads `...rest` onto both `<Image>` elements. The caller passes `alt="Turborepo logo"`, so both receive it — but only one is visible at a time. The invisible one is not `aria-hidden`, so screen readers announce the same image description twice in succession.

Why it matters

Screen readers read the hidden image's alt text alongside the visible one, creating a doubled announcement that confuses assistive tech users and fails WCAG 1.1.1.

Fix

Hide the inactive theme image from the accessibility tree with aria-hidden so only the visible image is announced.

<Image {...rest} src={srcLight} className="imgLight" />
<Image {...rest} src={srcDark} className="imgDark" />
<Image {...rest} src={srcLight} className="imgLight" />
<Image {...rest} src={srcDark} className="imgDark" alt="" aria-hidden />
Accessibility·examples/non-monorepo/app/page.tsx:19Serious

Body text in dark mode falls below AA contrast threshold

The paragraph text uses `dark:text-zinc-400` on a `dark:bg-black` background. zinc-400 is #a1a1aa and black is #000000 — this yields a contrast ratio of approximately 4.0:1, which fails WCAG AA for body text (requires 4.5:1).

Why it matters

Body text at 4.0:1 in dark mode fails WCAG 1.4.3. Low-vision users and anyone in a bright environment reading the dark variant cannot reliably distinguish the paragraph text.

Fix

Raise dark mode body text to zinc-300 or higher to meet 4.5:1 against black.

<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-300">
94/100

UX

3 serious
UX·examples/with-docker/apps/web/src/app/page.tsx:49Serious

Error state names the failure but offers no recovery path

The error block renders: - `<h3>Error</h3>` - `<p>Unable to fetch response</p>` There is no retry action. The only way to try again is to re-click Submit manually.

Why it matters

Users who hit a transient network error have no affordance to recover without re-reading the form and re-submitting — increasing friction on failure.

Fix

Pair every error message with a recovery action — at minimum a retry button that re-submits the last request.

<div>
  <h3>Error</h3>
  <p>{error}</p>
</div>
<div>
  <h2>Error</h2>
  <p>{error}</p>
  <Button type="button" onClick={() => onSubmit}>Retry</Button>
</div>
UX·examples/basic/apps/docs/app/page.tsx:68Serious

Two secondary actions at identical weight make no action feel primary

The CTA block contains three interactive elements: - `'Deploy now'` anchor (`styles.primary`) - `'Read our docs'` anchor (`styles.secondary`) - `'Open alert'` Button (`styles.secondary`) `'Read our docs'` and `'Open alert'` share the same visual class, so they read as equals. There is no hierarchy between a navigation action and a component demo trigger.

Why it matters

When two controls share identical visual treatment and neither is clearly subordinate, decision time increases — users have to read both to distinguish intent rather than scanning by affordance.

Fix

Use a visually distinct treatment (e.g., a plain text link or ghost variant) for the utility 'Open alert' demo button to signal it is secondary to the documentation link.

<Button appName="docs" className={styles.secondary}>
  Open alert
</Button>
<Button appName="docs" className={styles.tertiary}>
  Open alert
</Button>
UX·examples/kitchen-sink/apps/storefront/src/app/page.tsx:20Serious

External links open in new tab without warning users

Both <Link> elements — "Turborepo" and "Next.js" — use the `newTab` prop. Neither carries a visual indicator (icon) or aria annotation that a new tab will open, which violates expected link affordance.

Why it matters

Users who rely on keyboard navigation or assistive tech lose their place in the document without warning — WCAG 3.2.2 requires that unexpected context changes be announced.

Fix

Add an aria-label or visually-hidden span to new-tab links so the behavior is announced before activation.

<Link href="https://turborepo.dev" newTab>
  Turborepo
</Link>
<Link href="https://turborepo.dev" newTab aria-label="Turborepo (opens in new tab)">
  Turborepo
</Link>

Hierarchy

✓ No issues found

Typography

✓ No issues found

Color

✓ No issues found

Spacing

✓ No issues found

Components

✓ No issues found

Motion

✓ No issues found

Working well

  • The CTA pair — "Deploy Now" (filled bg-foreground) vs. "Documentation" (outline with border-black/[.08]) — gets visual hierarchy right without any extra work. The filled button reads as primary instantly because it's the only element on the page with solid background weight. This is the correct pattern: one action gets the filled treatment, the secondary action gets the ghost, and the eye never hesitates.
  • The 'Deploy now' link correctly uses `rel="noopener noreferrer"` on every external `target="_blank"` link throughout the page. This is easy to forget and commonly omitted in starter templates — it prevents the opened tab from accessing the opener's window object, closing a real security gap.
  • Using `border-black/[.08]` and `dark:border-white/[.145]` for the outline button border is a smart choice — opacity-based borders adapt naturally to any background color rather than snapping to a fixed gray. This is the right way to do borders on components that might sit on varied surfaces.
  • The ThemeImage component correctly forwards the `alt` prop from the parent call site rather than hardcoding it internally. This means the component is reusable without baking in a specific label, which is the right abstraction — the caller knows the semantic context, not the image switcher.

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