vercel/turbo
Reviewed against Rams quality heuristics — accessibility, hierarchy, color, typography, spacing, components, motion, and UX.
30 files reviewed·May 13, 2026
Also worth noting
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
Accessibility
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" />
</>
);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 />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">UX
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>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>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
Typography
Color
Spacing
Components
Motion
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