resend on GitHub

resend/react-email

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

30 files reviewed·August 1, 2026

View on GitHub

Elevated

Design risk in this codebase.

7issues
Critical & Serious · top 6 shown below

Top fix

Add prefers-reduced-motion guard to card hover translate

See the fix

Verdict

A tactile hover pattern with real keyboard-parity thought is let down by unfinished motion: no reduced-motion fallback and a mismatched easing curve between sibling animations. Biggest risk is accessibility debt hiding behind visual polish, fix the motion guard first, then the dead-end not-found page.

Files Rams reviewed

apps/web/src/app/components/[slug]/page.tsx

apps/web/src/app/components/page.tsx

packages/react-email/src/components/tailwind/e2e/nextjs/src/app/layout.tsx

packages/react-email/src/components/tailwind/e2e/nextjs/src/app/page.tsx

apps/web/src/app/components/get-imported-components-for.tsx

apps/web/src/app/editor/page.tsx

apps/web/src/app/layout.tsx

apps/web/src/app/templates/page.tsx

packages/ui/src/app/layout.tsx

packages/ui/src/app/page.tsx

packages/ui/src/app/preview/[...slug]/page.tsx

apps/web/src/app/editor/basic-editor/page.tsx

apps/web/src/app/editor/bubble-menu/page.tsx

apps/web/src/app/editor/buttons/page.tsx

apps/web/src/app/editor/column-layouts/page.tsx

apps/web/src/app/editor/custom-bubble-menu/page.tsx

apps/web/src/app/editor/custom-extensions/page.tsx

apps/web/src/app/editor/custom-theme/page.tsx

apps/web/src/app/editor/email-export/page.tsx

apps/web/src/app/editor/email-theming/page.tsx

apps/web/src/app/editor/full-email-builder/page.tsx

apps/web/src/app/editor/image-upload/page.tsx

apps/web/src/app/editor/inspector-composed/page.tsx

apps/web/src/app/editor/inspector-custom/page.tsx

apps/web/src/app/editor/inspector-defaults/page.tsx

apps/web/src/app/editor/layout.tsx

apps/web/src/app/editor/link-editing/page.tsx

apps/web/src/app/editor/slash-commands/page.tsx

apps/web/src/app/editor/standalone-editor-full/page.tsx

apps/web/src/app/editor/standalone-editor-inspector/page.tsx

95/100

Motion

1 critical1 serious
MotionCritical

apps/web/src/app/components/page.tsx:96

Card hover translate animation ignores prefers-reduced-motion entirely

The category cards in apps/web/src/app/components/page.tsx use `md:group-hover:-translate-x-2 md:group-hover:-translate-y-2 md:group-focus:-translate-x-2 md:group-focus:-translate-y-2` (and matching diagonal variants for other columns) to shift the whole card on hover and keyboard focus. Nothing in the file wraps these transforms in a `motion-reduce:` variant or a `prefers-reduced-motion` guard, so every visitor gets the full parallax-style shift regardless of their OS setting.

Why it matters

Vestibular-sensitive users who have set 'reduce motion' at the OS level still get the full translate animation on every card in the grid, which can cause real physical discomfort and fails WCAG 2.3.3 (Animation from Interactions).

Fix

Guard all translate/transform transitions with a `motion-reduce:transform-none` or `motion-reduce:transition-none` variant so reduced-motion users see the state change without the movement.

'group-focus:ring-slate-2 relative isolate z-2 flex cursor-pointer flex-col justify-end rounded-md bg-black p-4 group-focus:ring-3 md:transition-transform md:duration-240 md:ease-[cubic-bezier(.36,.66,.6,1)]',
{
  'md:group-hover:-translate-x-2 md:group-hover:-translate-y-2 md:group-focus:-translate-x-2 md:group-focus:-translate-y-2':
    index % 3 === 0,
}
'group-focus:ring-slate-2 relative isolate z-2 flex cursor-pointer flex-col justify-end rounded-md bg-black p-4 group-focus:ring-3 motion-reduce:transition-none md:transition-transform md:duration-240 md:ease-[cubic-bezier(.36,.66,.6,1)]',
{
  'motion-reduce:transform-none md:group-hover:-translate-x-2 md:group-hover:-translate-y-2 md:group-focus:-translate-x-2 md:group-focus:-translate-y-2':
    index % 3 === 0,
}
MotionSerious

apps/web/src/app/components/page.tsx:83

Card border transition uses an overshoot bezier at 720ms, out of step with sibling animations

The dashed `before` border on each category card in apps/web/src/app/components/page.tsx transitions with `md:before:transition-colors md:before:duration-720 md:before:ease-[cubic-bezier(.24,.9,.32,1.4)]`. That bezier's last control point (1.4) goes past 1.0, producing an overshoot/bounce on a plain color fade, and 720ms is far slower than the 240ms transform and 300ms inner-border transitions on the same card.

Why it matters

A bouncy 720ms fade on a border color change reads as sluggish and inconsistent next to the 240ms card lift and 300ms inner border on the same hover interaction, so the card's hover state feels like three uncoordinated animations firing at once.

Fix

Match the border color transition duration and easing to the other transitions on the same card (e.g. 300ms with a standard ease-out) so the hover state reads as one coordinated motion.

'group relative isolate mt-7 cursor-pointer scroll-m-6 rounded-md focus:outline-hidden focus:ring-3 focus:ring-slate-2 md:before:absolute md:before:inset-0 md:before:rounded-md md:before:border md:before:border-slate-4 md:before:border-dashed md:before:transition-colors md:before:duration-720 md:before:ease-[cubic-bezier(.24,.9,.32,1.4)] md:focus:before:border-slate-6 md:hover:before:border-slate-6'
'group relative isolate mt-7 cursor-pointer scroll-m-6 rounded-md focus:outline-hidden focus:ring-3 focus:ring-slate-2 md:before:absolute md:before:inset-0 md:before:rounded-md md:before:border md:before:border-slate-4 md:before:border-dashed md:before:transition-colors md:before:duration-300 md:before:ease-[cubic-bezier(.36,.66,.6,1)] md:focus:before:border-slate-6 md:hover:before:border-slate-6'
98/100

Color

1 serious
ColorSerious

apps/web/src/app/layout.tsx:93

color-scheme set as a bare HTML attribute has no visual effect

In apps/web/src/app/layout.tsx, the `<html>` element carries `color-scheme="dark"` as a plain attribute rather than a CSS property. Browsers only read `color-scheme` from CSS (a `style` declaration, a `:root` rule, or the `color-scheme` meta tag), so this attribute is inert.

Why it matters

Native browser chrome, scrollbars, and unstyled form controls (checkboxes, date pickers) keep rendering in light mode even though the rest of the page commits to a dark theme, creating a visible light/dark mismatch at the OS-UI boundary.

Fix

Set color-scheme through CSS (inline style or a global stylesheet rule) instead of a bare HTML attribute so the browser actually applies it.

<html
  className={`${inter.variable} ${commitMono.variable} antialiased`}
  lang="en"
  color-scheme="dark"
>
<html
  className={`${inter.variable} ${commitMono.variable} antialiased`}
  lang="en"
  style={{ colorScheme: 'dark' }}
>

Get this score on every PR.

Rams reviews each pull request on your repo and posts inline one-click fixes — about a minute per review.

Install Rams free
98/100

Spacing

1 serious
Design SystemSerious

apps/web/src/app/components/[slug]/page.tsx:107

Arrow icon uses a 1px arbitrary offset instead of flex alignment

The back-arrow icon in apps/web/src/app/components/[slug]/page.tsx carries `className="mt-[.0625rem]"`, a hand-tuned 1px nudge to visually align it with the 'Back' text, bypassing the project's spacing scale entirely.

Why it matters

One-off arbitrary values like this drift over time as icon sizes or font metrics change elsewhere, and nobody reviewing the diff later knows why this specific pixel value exists, so the alignment silently breaks the next time the icon or font is swapped.

Fix

Use the parent's flex alignment (already `items-center`) to vertically center the icon instead of a manual pixel offset.

<IconArrowLeft className="mt-[.0625rem]" size={14} />
<IconArrowLeft size={14} />
98/100

Components

1 serious
ComponentsSerious

apps/web/src/app/components/page.tsx:71

Lazy illustration component gets recreated on every render inside the map

Inside `componentsStructure.map()` in apps/web/src/app/components/page.tsx, `const Illustration = dynamic(() => import(...))` is called fresh for every render of the component list, not hoisted or memoized. Each render produces a new lazy-component identity for the same category, even though the underlying module never changes.

Why it matters

React treats each new `dynamic()` result as a different component type, so any re-render of this page (route transition, parent state change) forces every illustration to unmount and remount from its loading state, causing a visible flash instead of a stable image.

Fix

Build the map of dynamic illustration components once outside the render path (e.g. a module-level lookup keyed by category name) so identity stays stable across renders.

{componentsStructure.map((category, index) => {
  const slug = slugify(category.name);
  const Illustration = dynamic(
    () =>
      import(
        `@/illustrations/${category.name
          .toLowerCase()
          .replace(/ /g, '-')}`
      ),
  );
const illustrationsByCategory = Object.fromEntries(
  componentsStructure.map((category) => [
    category.name,
    dynamic(() =>
      import(`@/illustrations/${category.name.toLowerCase().replace(/ /g, '-')}`),
    ),
  ]),
);

// inside map:
{componentsStructure.map((category, index) => {
  const slug = slugify(category.name);
  const Illustration = illustrationsByCategory[category.name];
98/100

UX

1 serious
UXSerious

apps/web/src/app/components/[slug]/page.tsx:68

Missing category renders a dead-end paragraph with no way back

When `foundCategory` isn't found, apps/web/src/app/components/[slug]/page.tsx returns just `<p>Component category not found.</p>`: no heading, no styling, no link. Every other state in this route is wrapped in `PageWrapper`/`PageTransition` with a 'Back' link to `/components`, but this early return skips all of it.

Why it matters

A user who lands on a stale or mistyped category URL hits a bare sentence with zero navigation, forcing them to use the browser back button or manually edit the URL to recover instead of a single click.

Fix

Give not-found states the same navigational chrome as the rest of the route, including a link back to the parent list.

if (!foundCategory) {
    return <p>Component category not found.</p>;
  }
if (!foundCategory) {
    return (
      <PageWrapper>
        <div className="flex w-full flex-col gap-4 px-6 pt-16 pb-10 md:px-8">
          <p>Component category not found.</p>
          <Link className="text-slate-11 underline hover:text-slate-12" href="/components">
            Back to components
          </Link>
        </div>
      </PageWrapper>
    );
  }

Accessibility

No issues found

Typography

No issues found

Craft

No issues found

Working well

  • Both typefaces load through next/font/local with display: 'swap' and preload: true, avoiding invisible text and third-party font round-trips, and they're wired through CSS variables (inter.variable, commitMono.variable) rather than hardcoded font-family strings, which keeps the type system themeable.
  • The category cards apply group-focus alongside group-hover for both the border color and translate states, so keyboard-focused cards get the same affordance as mouse-hovered ones instead of a silently degraded experience.
  • The 'Back' link in [slug]/page.tsx gives real visible focus feedback (focus:bg-slate-6 focus:ring-3 focus:ring-slate-3) instead of stripping the outline, so keyboard users can see exactly where they are.

Scored August 1, 2026 with Rams Engine v0.0.3 · Engine changelog
First scored July 9, 2026: 92/100. This rescore on v0.0.3: 59/100.

This page is an automated design review of resend/react-email’s UI code: 30 files read against 309 versioned rules covering accessibility, color, typography, spacing, components, UX, motion, and craft. The score is out of 100; confirmed criticals cap it — one at 59, two at 49, three or more at 39.

More design scores

Score your own repo.

Free on public repos, no account. The same engine that scored this page reads your UI code and mints a score page like this one.

Public repos only. The full engine reviews the UI code and mints a public score page — we email you the link too. Already-scored repos open instantly.

Or get a design review on every pull requestInstall Rams