Skip to main content

Command Palette

Search for a command to run...

Email-safe shadcn/ui components for Playwright test reports

How we rebuilt shadcn primitives without Radix UI to work with react-email

Updated
•4 min read•View as Markdown
Email-safe shadcn/ui components for Playwright test reports
V
SDET. Certified Node.js Application Developer(JSNAD)

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.

Chart shadcn Template Example

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.

Button shadcn Template

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