The Unreasonable Effectiveness of Generating UI with React and Tailwind

Dan Corin January 6, 2025
Source

import Aside from '@components/prose/Aside.astro'; import Chat from '@components/prose/Chat.astro'; import CodeBlock from '@components/prose/CodeBlock.astro'; import { Image } from 'astro:assets';

If you've experimented with Claude Artifacts or v0.dev, maybe you've been delighted (as I have) that the language model can conjure a UI for an idea you describe. Most of my experience building software, especially professionally, comes from working on the "backend". Building frontend (read: user interfaces in a browser) is harder for me (or doesn't come as easily), because while I have experience writing software, I don't have as much experience writing this type of software.

The "Magic" Stack

When generating UI, one specific stack has proven to be both popular and effective relative to the technologies I have tried. That stack is React and Tailwind.

Both Claude Artifacts and v0 use these technologies by default and there is a reason why. For the language model, co-locating the component styling with the structural markup is highly effective and steerable by prompting.

Comparing Different Approaches

Given the following prompt, here's what claude-3-5-sonnet-20241022 generates in Cursor (the components below are real, working React code - try them out!).

I made a few minor adjustments post-generation to the components to ensure they render nicely on small screens I made minor adjustments to the color schemes of the components after generation to play nice with dark mode of this site. The changes I made keep the color scheme true to what the model output rather than match the site style.

<Chat model="claude-3-5-sonnet-20241022" messages={[ { role: 'user', content: 'use react, tailwind and lucide-react to create an interface for a bullet-journal inspired calendar', }, ]} />

import { default as TailwindCalendarV1 } from './components/TailwindCalendarv1'; import TailwindCalendarV1Source from './components/TailwindCalendarv1?raw';

<Chat model="claude-3-5-sonnet-20241022" messages={[ { role: 'user', content: 'use react and lucide-react to create an interface for a bullet-journal inspired calendar. output the react component with styles and a corresponding css module', }, ]} />

import { default as ReactCalendarV1 } from './components/ReactCalendar.v1'; import ReactCalendarV1Source from './components/ReactCalendar.v1?raw';

Not much difference between these two from the user's perspective. Pretty straightforward LLM UI output with lots of opportunities for improvement.

The former component exists entirely within a single .tsx file. The latter has the markup and style separated into .tsx and .css.module files.

Iterating on the Initial Design

Inevitably, we will want to make changes to the first iteration from the model. Let's add a calendar component so we can see the number of tasks that have been input on each day.

<Chat model="claude-3-5-sonnet-20241022" messages={[ { role: 'user', content: 'update the component to add a calendar ui element. the calendar should display the count of the number of tasks and their types each day', }, ]} />

import { default as TailwindCalendarV2 } from './components/TailwindCalendarv2'; import TailwindCalendarV2Source from './components/TailwindCalendarv2?raw';

The Multi-File Challenge

To do the same for our two-file approach, we now need to start thinking about what context we're going to provide to the model. Ideally, we would generate diffs to the existing files, as this is one of the faster and more efficient ways we could make changes. There are many new tools available that can facilitate multi-file changes but needing to coordinate changes across multiple files adds complexity compared to the single-file approach. For simplicity, let's send both files to the LLM and make the same ask (I'll @-ref both files in Cursor in that chat, then send the same prompt from above).

!Screenshot showing how to reference multiple files in Cursor by using @ symbol

Cursor outputs code like

and

so now I manually need to apply these changes (Cursor Pro has a model that supports one-click application of code changes from chat, but I'm trying to stick to just using the model for now).

Oh, and it looks like the model actually introduced new code using dependencies we don't have installed

This type of things (arguably a hallucination of sorts) happens a lot less when making changes in a single file. I hear you, it's not a fair comparison: Tailwind in-file to CSS with a separate module file. Let's put all the CSS styles in the same file then compare what it's like to iterate.

<Chat messages={[ { role: 'user', content: 'use react and lucide-react to create an interface for a bullet-journal inspired calendar. output the react component with css styles all in one file', }, ]} />

import { default as CssCalendarV1 } from './components/CssCalendarv1'; import CssCalendarV1Source from './components/CssCalendarv1?raw';

Now, let's transform that single file with the second prompt

<Chat messages={[ { role: 'user', content: 'update the component to add a calendar ui element. the calendar should display the count of the number of tasks and their types each day', }, ]} />

import { default as CssCalendarV2 } from './components/CssCalendarv2'; import CssCalendarV2Source from './components/CssCalendarv2?raw';

Not bad! So what am I complaining about?

Using a model to prompt for edits to a React component with styles defined as CSS, even in the same file, pretty much always requires a full-file rewrite.

Why?

The React component with in-file CSS styles has the following structure

Comparing diffs

When we prompt to make updates in the UI, we will almost always be making changes in areas 1 and 3 to implement new or modified functionality.

With Tailwind, the styles live right on the React markup as classNames. We can easily highlight and select smaller regions of the code and prompt the model using a tool like Cursor to make changes. This approach is more token-efficient and as a result, is faster and cheaper. It also encourages a more holistic understanding of the code you're working with as a developer. In my experience using models to generate code, the less thinking you do or the less you understand, the less likely what you're attempting is going to work.

Here is the diff between the two versions of the component with CSS styles in-file:

Compare these two distinct areas of change to the changes in the versions using Tailwind:

The Efficiency of Tailwind with LLMs

A diff of about half the number of lines is needed to make the prompted changes in the Tailwind component. This does not suggest a Tailwind approach is necessarily better than others, but rather that a language model is more effective at following instructions requiring small, more localized modifications. Thus, if you use Tailwind/React, you have an easier time iterating and building with a language partner than several other approaches and project structures. This understanding is implicitly reflected in the approaches taken by default (presumably prompted into) tools like Claude Artifacts and v0. However, not all models and tools take this approach by default - it's not the only way to quickly build UIs with language models, but it is a highly effective and fast way.

For completeness, here are some tools that don't seem to use React and Tailwind by default:

  • Val Town's Townie
  • ChatGPT Canvas

Model Biases and Context Influence

It's also possible that claude-3-5-sonnet-20241022, one of the most popular models for coding, has a propensity for writing React/Tailwind code, which could influence the approaches taken by popular tools. When prompting Claude with similar prompts as above, I usually get React code, even when I don't ask for it. With other popular models like deepseek-chat or gpt-4, I don't always get React. Sometimes I just get plain HTML or other frameworks like Vue (when I prompt for everything in a single file).

Part of the behavior we're seeing is context-specific as well. When you prompt the model to create a component like the one we've described, if I am working in Cursor and in a file called .tsx, that gets passed to the model as context and influences the code it generates. It would definitely not be what we wanted if we got Vue code in our .tsx file.

As someone who has leaned heavily on LLMs to write UIs for me, I've found React and Tailwind to be a particularly potent combination for fast iteration. After diving into the actual code structure above, why that is now makes a bit more sense.

Discussion in the ATmosphere

Loading comments...