resend/react-email
Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.
30 files reviewed·August 1, 2026
Elevated
Design risk in this codebase.
More findings
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
Motion
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,
}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'Color
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 freeSpacing
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} />Components
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];UX
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
Typography
Craft
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 291 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.