Email-safe shadcn/ui components for Playwright test reports
How we rebuilt shadcn primitives without Radix UI to work with react-email

The goal
@playwright-labs/reporter-email is a Playwright reporter that sends an HTML email when a test run finishes. In this release we wanted to ship templates that use shadcn/ui components — the same Badge, Card, Button, and Select that developers already know — while keeping full compatibility with email clients.
The catch: standard shadcn doesn't work in email. This post explains why and how we solved it.
Why shadcn/ui breaks in email
There are four problems:
1. Radix UI requires browser APIs. shadcn components delegate behaviour (focus management, ARIA, keyboard nav) to Radix UI primitives. Radix calls window, document, ResizeObserver, and MutationObserver at import time. @react-email/render runs in Node.js — none of these exist. Result: crash on import.
2. CSS variables don't work in email clients. shadcn defaults to hsl(var(--background)) style design tokens. Gmail, Outlook, and Apple Mail strip or ignore CSS variables. Every color becomes transparent or falls back to black.
3. Dark mode classes generate unsupported media queries. Tailwind's dark: prefix generates @media (prefers-color-scheme: dark) blocks. Most email clients strip <style> tags entirely, let alone media queries.
4. <button> elements are unreliable in email. Interactive elements in email HTML are famously inconsistent. <a href> links have near-universal support; <button> does not.
The solution
No Radix UI
Every component is rewritten as a plain HTML element with the matching visual style. Zero Radix UI imports. Here's the full SelectItem implementation:
/* @jsxImportSource react */
function SelectItem({ className, children, selected, ...props }: SelectItemProps) {
return (
<div
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm outline-none",
selected ? "bg-slate-100 font-medium text-slate-900" : "text-slate-700",
className,
)}
{...props}
>
{selected && (
<span style={{ marginRight: "8px", fontSize: "10px" }}>✓</span>
)}
{children}
</div>
)
}
That's the entire interactive component — a <div> with conditional classes. Static, server-renderable, email-compatible.
cssVariables: false in components.json
{
"tailwind": {
"baseColor": "slate",
"cssVariables": false
}
}
This makes shadcn generate bg-slate-900 instead of bg-background. Concrete class names → concrete inline CSS values after @react-email/components's <Tailwind> wrapper processes them.
/* @jsxImportSource react */ pragma
Playwright uses esbuild internally, which by default transforms JSX using Playwright's own runtime. This breaks @react-email/render because it expects React's createElement calls, not Playwright's internal representation.
The fix is a per-file pragma at the top of each .tsx component:
/* @jsxImportSource react */
esbuild respects this override and uses React's JSX runtime for that file. Template files that call React.createElement directly are kept as .ts to avoid the JSX transform entirely.
Button as <a>
/* @jsxImportSource react */
function Button({ href, children, variant, ...props }: ButtonProps) {
return (
<a
href={href}
className={cn(buttonVariants({ variant }))}
{...props}
>
{children}
</a>
)
}
All the shadcn Button variants (default, destructive, outline, secondary, ghost, link) exist — just rendered as links.
New templates
Chart template
import { PlaywrightReportShadcnChartEmail }
from "@playwright-labs/reporter-email/templates/shadcn/base-chart";
Adds a "Pass rate" stacked bar above the test list. The bar is three coloured <div> elements with flex widths calculated from test counts — no canvas, no SVG.
Button template
import { PlaywrightReportShadcnButtonEmail }
from "@playwright-labs/reporter-email/templates/shadcn/base-button";
<PlaywrightReportShadcnButtonEmail
result={result}
testCases={testCases}
reportUrl="https://ci.example.com/report/42"
failuresUrl="https://ci.example.com/report/42/failures"
/>
A CardFooter with CTA links. "View N Failure(s)" only renders when failed > 0.
Themes template
import { PlaywrightReportShadcnThemesEmail, type ShadcnTheme }
from "@playwright-labs/reporter-email/templates/shadcn/base-themes";
<PlaywrightReportShadcnThemesEmail result={result} testCases={testCases} theme="blue" />
Six themes: slate | zinc | rose | blue | green | orange. Each theme is a ThemePalette record with six hex values covering accent, headings, borders, and row backgrounds. Status colors (green for pass, red for fail) stay constant across themes.
Subpath exports
dist/
templates/shadcn/index.{mjs,cjs,d.ts,d.cts}
templates/shadcn/base.{mjs,cjs,d.ts,d.cts}
templates/shadcn/base-chart.{mjs,cjs,d.ts,d.cts}
templates/shadcn/base-button.{mjs,cjs,d.ts,d.cts}
templates/shadcn/base-themes.{mjs,cjs,d.ts,d.cts}
templates/shadcn/base-select.{mjs,cjs,d.ts,d.cts}
The package.json exports field maps each path with separate import (ESM) and require (CJS) conditions, each with its own .d.ts / .d.cts. Requires "moduleResolution": "bundler" in tsconfig.json to resolve correctly.
Try it locally
git clone https://github.com/vitalics/playwright-labs
cd playwright-labs
pnpm install
pnpm --filter @playwright-labs/reporter-email build
cd packages/reporter-email/examples
pnpm email:preview
# → http://localhost:3000
The dev server hot-reloads on file changes. Edit the constants at the top of any preview file to change the theme, filter, or test data.
GitHub: https://github.com/vitalics/playwright-labs
npm: @playwright-labs/reporter-email



