iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🖊

Zero-Install: Running a React + TSX + Tailwind Stack in a Single HTML File

に公開

About how to run a minimal frontend stack in a single HTML file for prototyping.

Note: Do not use this in production. Tailwind is running in CDN mode, and esm.sh builds scripts dynamically, so performance is not ideal.

Background

At jsconf.jp, I talked about how you can achieve a "bundle-less" feel using various tools (though there are performance trade-offs).

https://jsconfjp-slide.pages.dev/

Specifically, it uses a combination of Native ESM + import maps + esm.sh, etc.

<script type="importmap"> - HTML: HyperText Markup Language | MDN

ESM>CDN

This article is about combining that with the new feature in esm.sh v135 to bundle TSX.

https://github.com/esm-dev/esm.sh/releases/tag/v135

esm.sh/run

Usage is simple.

  <!-- Load the runner from esm.sh -->
  <script type="module" src="https://esm.sh/run" defer></script>

  <!-- text/babel -->
  <script type="text/babel">
    // code here
  </script>

It's pretty much the same as the classic babel/standalone.

You can technically run TSX using text/tsx as well... but VS Code doesn't support <script type=text/tsx>, so syntax highlighting won't work, and there's currently no way to run type-checking for inline TypeScript anyway.

Since all you likely want in this file scope is JSX transpilation, text/babel is recommended because VS Code supports it.

You can also try it in the Playground.

https://code.esm.sh/?template=run

Babel + React + Tailwind

A simple example.

Configure Tailwind via CDN and use import maps to pull in React.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script type="importmap">
    {
      "imports": {
        "@jsxImportSource": "https://esm.sh/react@18",
        "react": "https://esm.sh/react@18",
        "react-dom/client": "https://esm.sh/react-dom@18/client"
      }
    }
  </script>
  <script type="module" src="https://esm.sh/run" defer></script>
</head>
<body>
  <div id="root"></div>
  <script type="text/babel">
    import { createRoot } from "react-dom/client";
    const App = () => {
      return <div className="bg-red-400">
        Hello World
      </div>;
    };
    const root = createRoot(document.querySelector('#root'));
    root.render(<App />);
  </script>
</body>
</html>

Save this as index.html in a suitable location and open it as a local file in Chrome (e.g., using open index.html).

It worked.

If you just want to quickly try out a React library, this seems like a good way to go.

shadcn-ui/react

As a slightly more real-world, complex example, let's embed code generated by shadcn-ui/react. The internals of shadcn-ui/react are Tailwind + Radix UI.

shadcn-ui/ui: Beautifully designed components built with Radix UI and Tailwind CSS.

Tailwind settings are written to the global tailwind.config, containing information equivalent to tailwind.config.js.

  <script>
    tailwind.config = {/**/}
  </script>

Try Tailwind CSS using the Play CDN - Tailwind CSS

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <style type="text/tailwindcss">
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    @layer base {
      :root {
        --background: 0 0% 100%;
        --foreground: 222.2 84% 4.9%;

        --card: 0 0% 100%;
        --card-foreground: 222.2 84% 4.9%;
    
        --popover: 0 0% 100%;
        --popover-foreground: 222.2 84% 4.9%;
    
        --primary: 222.2 47.4% 11.2%;
        --primary-foreground: 210 40% 98%;
    
        --secondary: 210 40% 96.1%;
        --secondary-foreground: 222.2 47.4% 11.2%;
    
        --muted: 210 40% 96.1%;
        --muted-foreground: 215.4 16.3% 46.9%;
    
        --accent: 210 40% 96.1%;
        --accent-foreground: 222.2 47.4% 11.2%;
    
        --destructive: 0 84.2% 60.2%;
        --destructive-foreground: 210 40% 98%;

        --border: 214.3 31.8% 91.4%;
        --input: 214.3 31.8% 91.4%;
        --ring: 222.2 84% 4.9%;
    
        --radius: 0.5rem;
      }
    
      .dark {
        --background: 222.2 84% 4.9%;
        --foreground: 210 40% 98%;
    
        --card: 222.2 84% 4.9%;
        --card-foreground: 210 40% 98%;
    
        --popover: 222.2 84% 4.9%;
        --popover-foreground: 210 40% 98%;
    
        --primary: 210 40% 98%;
        --primary-foreground: 222.2 47.4% 11.2%;
        --secondary: 217.2 32.6% 17.5%;
        --secondary-foreground: 210 40% 98%;
        --muted: 217.2 32.6% 17.5%;
        --muted-foreground: 215 20.2% 65.1%;
        --accent: 217.2 32.6% 17.5%;
        --accent-foreground: 210 40% 98%;
        --destructive: 0 62.8% 30.6%;
        --destructive-foreground: 210 40% 98%;
        --border: 217.2 32.6% 17.5%;
        --input: 217.2 32.6% 17.5%;
        --ring: 212.7 26.8% 83.9%;
      }
    }
 
    @layer base {
      * {
        @apply border-border;
      }
      body {
        @apply bg-background text-foreground;
      }
    }
  </style>
  <script>
    tailwind.config = {
      theme: {
        extend: {
          colors: {
            border: "hsl(var(--border))",
            input: "hsl(var(--input))",
            ring: "hsl(var(--ring))",
            background: "hsl(var(--background))",
            foreground: "hsl(var(--foreground))",
            primary: {
              DEFAULT: "hsl(var(--primary))",
              foreground: "hsl(var(--primary-foreground))",
            },
            secondary: {
              DEFAULT: "hsl(var(--secondary))",
              foreground: "hsl(var(--secondary-foreground))",
            },
            destructive: {
              DEFAULT: "hsl(var(--destructive))",
              foreground: "hsl(var(--destructive-foreground))",
            },
            muted: {
              DEFAULT: "hsl(var(--muted))",
              foreground: "hsl(var(--muted-foreground))",
            },
            accent: {
              DEFAULT: "hsl(var(--accent))",
              foreground: "hsl(var(--accent-foreground))",
            },
            popover: {
              DEFAULT: "hsl(var(--popover))",
              foreground: "hsl(var(--popover-foreground))",
            },
            card: {
              DEFAULT: "hsl(var(--card))",
              foreground: "hsl(var(--card-foreground))",
            },
          },
          borderRadius: {
            lg: "var(--radius)",
            md: "calc(var(--radius) - 2px)",
            sm: "calc(var(--radius) - 4px)",
          },
          keyframes: {
            "accordion-down": {
              from: { height: 0 },
              to: { height: "var(--radix-accordion-content-height)" },
            },
            "accordion-up": {
              from: { height: "var(--radix-accordion-content-height)" },
              to: { height: 0 },
            },
          },
          animation: {
            "accordion-down": "accordion-down 0.2s ease-out",
            "accordion-up": "accordion-up 0.2s ease-out",
          },
        },
      }
    }
  </script>
  <script type="importmap">
    {
      "imports": {
        "@jsxImportSource": "https://esm.sh/react@18",
        "@radix-ui/react-slot": "https://esm.sh/@radix-ui/react-slot",
        "react": "https://esm.sh/react@18",
        "react-dom/client": "https://esm.sh/react-dom@18/client",
        "class-variance-authority": "https://esm.sh/class-variance-authority",
        "clsx": "https://esm.sh/clsx",
        "tailwind-merge": "https://esm.sh/tailwind-merge"
      }
    }
  </script>
  <script type="module" src="https://esm.sh/run" defer></script>
</head>

<body>
  <div id="root"></div>
  <script type="text/babel">
    import { forwardRef } from "react";
    import { createRoot } from "react-dom/client";
    import { cva } from "class-variance-authority";
    import { Slot } from "@radix-ui/react-slot";
    import { clsx } from "clsx";
    import { twMerge } from "tailwind-merge";

    export function cn(...inputs) {
      return twMerge(clsx(inputs));
    }

    const buttonVariants = cva(
      "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
      {
        variants: {
          variant: {
            default: "bg-primary text-primary-foreground hover:bg-primary/90",
            destructive:
              "bg-destructive text-destructive-foreground hover:bg-destructive/90",
            outline:
              "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
            secondary:
              "bg-secondary text-secondary-foreground hover:bg-secondary/80",
            ghost: "hover:bg-accent hover:text-accent-foreground",
            link: "text-primary underline-offset-4 hover:underline",
          },
          size: {
            default: "h-10 px-4 py-2",
            sm: "h-9 rounded-md px-3",
            lg: "h-11 rounded-md px-8",
            icon: "h-10 w-10",
          },
        },
        defaultVariants: {
          variant: "default",
          size: "default",
        },
      },
    );

    export const Button = forwardRef(
      ({ className, variant, size, asChild = false, ...props }, ref) => {
        const Comp = asChild ? Slot : "button";
        return (
          <Comp
            className={cn(buttonVariants({ variant, size, className }))}
            ref={ref}
            asChild={asChild}
            {...props}
          />
        );
      },
    );
    Button.displayName = "Button";

    const App = () => {
      return <div>
        <Button variant="outline">
          Hello World
        </Button>
      </div>;
    };
    const root = createRoot(document.querySelector('#root'));
    root.render(<App />);
  </script>
</body>
</html>

It worked.

It was quite complex, but it still runs. However, as far as I can tell from the playground, the preview speed isn't very fast.

While it wasn't an issue this time, there is no way to represent the loading of external libraries specified in the plugins of tailwind.config.js.

Devtools Source

Now that we've come this far, I've started wanting to complete the development environment within the browser as well.

Mount the file itself in the Sources tab of DevTools.

It works. Edit it and reload manually to update. (Maybe I could implement auto-reload with self-monitoring if I tried?)

A bit disappointingly, while the built-in DevTools editor supported <script type="text/typescript">, <script type="text/tsx"> was a no-go.

<script type="text/tsx" src="./main.tsx"></script> also didn't work. If this worked, we could write in TSX (except for nested imports)...

Thx jexia_ (author of esm.sh)

After I posted on Twitter about building the parts other than esm.sh/run, the author of esm.sh reached out to let me know. Much appreciated!

https://twitter.com/jexia_/status/1727069200072741122

If I can just resolve the lack of highlighting for text/tsx in VS Code, find a way to run an LSP, and ideally find a formatting method, the experience would be even better. I might be able to repurpose the implementation I previously built to run an LSP within Markdown code blocks.

Created a VSCode extension to run LSP in Markdown code blocks

For the esm.sh/run part, it might be a good idea to run an LSP with my own implementation.

Discussion