The Unreasonable Effectiveness of Generating UI with React and Tailwind

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.

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!).

note

I made a few minor adjustments post-generation to the components to ensure they render nicely on small screens

use react, tailwind and lucide-react to create an interface for a bullet-journal inspired calendar

Bullet Journal

Show Source
import { Calendar as CalendarIcon, Circle, CheckCircle2, Square, XCircle } from 'lucide-react'
import { useState } from 'react'

type Task = {
    id: string
    text: string
    status: 'todo' | 'done' | 'migrated' | 'cancelled'
}

type DayEntry = {
    date: Date
    tasks: Task[]
}

export default function Calendar() {
    const [entries, setEntries] = useState<DayEntry[]>([])
    const [selectedDate, setSelectedDate] = useState<Date>(new Date())
    const [newTask, setNewTask] = useState('')

    const addTask = () => {
        if (!newTask.trim()) return

        setEntries(prev => {
            const existingEntry = prev.find(entry =>
                entry.date.toDateString() === selectedDate.toDateString()
            )

            if (existingEntry) {
                return prev.map(entry =>
                    entry.date.toDateString() === selectedDate.toDateString()
                        ? {
                            ...entry,
                            tasks: [...entry.tasks, {
                                id: crypto.randomUUID(),
                                text: newTask,
                                status: 'todo'
                            }]
                        }
                        : entry
                )
            }

            return [...prev, {
                date: selectedDate,
                tasks: [{
                    id: crypto.randomUUID(),
                    text: newTask,
                    status: 'todo'
                }]
            }]
        })

        setNewTask('')
    }

    const updateTaskStatus = (taskId: string, status: Task['status']) => {
        setEntries(prev =>
            prev.map(entry => ({
                ...entry,
                tasks: entry.tasks.map(task =>
                    task.id === taskId ? { ...task, status } : task
                )
            }))
        )
    }

    const getStatusIcon = (status: Task['status']) => {
        switch (status) {
            case 'todo': return <Circle className="w-4 h-4 sm:w-5 sm:h-5 text-gray-600" />
            case 'done': return <CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-green-600" />
            case 'migrated': return <Square className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
            case 'cancelled': return <XCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600" />
        }
    }

    const currentEntry = entries.find(entry =>
        entry.date.toDateString() === selectedDate.toDateString()
    )

    return (
        <div className="w-full max-w-2xl mx-auto p-4 sm:p-8 bg-white rounded-lg shadow-md">
            <div className="flex items-center gap-2 sm:gap-4 mb-4 sm:mb-8">
                <CalendarIcon className="w-5 h-5 sm:w-6 sm:h-6 text-gray-600" />
                <h1 className="text-xl sm:text-2xl font-semibold">Bullet Journal</h1>
            </div>

            <div className="mb-4 sm:mb-8">
                <input
                    type="date"
                    value={selectedDate.toISOString().split('T')[0]}
                    onChange={e => setSelectedDate(new Date(e.target.value))}
                    className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                />
            </div>

            <div className="flex flex-col sm:flex-row gap-2 sm:gap-4 mb-4 sm:mb-8">
                <input
                    type="text"
                    value={newTask}
                    onChange={e => setNewTask(e.target.value)}
                    placeholder="Add new task..."
                    onKeyPress={e => e.key === 'Enter' && addTask()}
                    className="flex-1 px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                />
                <button
                    onClick={addTask}
                    className="px-3 sm:px-4 py-2 text-sm sm:text-base bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
                >
                    Add
                </button>
            </div>

            <div className="space-y-2">
                {currentEntry?.tasks.map(task => (
                    <div key={task.id} className="flex items-center gap-2 p-2 border border-gray-200 rounded-md">
                        <button
                            onClick={() => {
                                const nextStatus: Record<Task['status'], Task['status']> = {
                                    todo: 'done',
                                    done: 'migrated',
                                    migrated: 'cancelled',
                                    cancelled: 'todo'
                                }
                                updateTaskStatus(task.id, nextStatus[task.status])
                            }}
                            className="focus:outline-none"
                        >
                            {getStatusIcon(task.status)}
                        </button>
                        <span className={`text-sm sm:text-base ${task.status === 'done' ? 'line-through text-gray-500' : ''}`}>
                            {task.text}
                        </span>
                    </div>
                ))}
            </div>
        </div>
    )
}

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

Bullet Journal

Show Source
import { Calendar as CalendarIcon, Circle, CheckCircle2, Square, XCircle } from 'lucide-react'
import { useState } from 'react'
import styles from './ReactCalendar.v1.module.css'

type Task = {
    id: string
    text: string
    status: 'todo' | 'done' | 'migrated' | 'cancelled'
}

type DayEntry = {
    date: Date
    tasks: Task[]
}

export default function Calendar() {
    const [entries, setEntries] = useState<DayEntry[]>([])
    const [selectedDate, setSelectedDate] = useState<Date>(new Date())
    const [newTask, setNewTask] = useState('')

    const addTask = () => {
        if (!newTask.trim()) return

        setEntries(prev => {
            const existingEntry = prev.find(entry =>
                entry.date.toDateString() === selectedDate.toDateString()
            )

            if (existingEntry) {
                return prev.map(entry =>
                    entry.date.toDateString() === selectedDate.toDateString()
                        ? {
                            ...entry,
                            tasks: [...entry.tasks, {
                                id: crypto.randomUUID(),
                                text: newTask,
                                status: 'todo'
                            }]
                        }
                        : entry
                )
            }

            return [...prev, {
                date: selectedDate,
                tasks: [{
                    id: crypto.randomUUID(),
                    text: newTask,
                    status: 'todo'
                }]
            }]
        })

        setNewTask('')
    }

    const updateTaskStatus = (taskId: string, status: Task['status']) => {
        setEntries(prev =>
            prev.map(entry => ({
                ...entry,
                tasks: entry.tasks.map(task =>
                    task.id === taskId ? { ...task, status } : task
                )
            }))
        )
    }

    const getStatusIcon = (status: Task['status']) => {
        switch (status) {
            case 'todo': return <Circle className={styles.icon} />
            case 'done': return <CheckCircle2 className={styles.icon} />
            case 'migrated': return <Square className={styles.icon} />
            case 'cancelled': return <XCircle className={styles.icon} />
        }
    }

    const currentEntry = entries.find(entry =>
        entry.date.toDateString() === selectedDate.toDateString()
    )

    return (
        <div className={styles.container}>
            <div className={styles.header}>
                <CalendarIcon className={styles.calendarIcon} />
                <h1>Bullet Journal</h1>
            </div>

            <meta name="viewport" content="width=device-width, initial-scale=1.0" />

            <div className={styles.dateSelector}>
                <input
                    type="date"
                    value={selectedDate.toISOString().split('T')[0]}
                    onChange={e => setSelectedDate(new Date(e.target.value))}
                />
            </div>

            <div className={styles.taskInput}>
                <input
                    type="text"
                    value={newTask}
                    onChange={e => setNewTask(e.target.value)}
                    placeholder="Add new task..."
                    onKeyPress={e => e.key === 'Enter' && addTask()}
                />
                <button onClick={addTask}>Add</button>
            </div>

            <div className={styles.taskList}>
                {currentEntry?.tasks.map(task => (
                    <div key={task.id} className={styles.task}>
                        <button
                            className={styles.statusButton}
                            onClick={() => {
                                const nextStatus: Record<Task['status'], Task['status']> = {
                                    todo: 'done',
                                    done: 'migrated',
                                    migrated: 'cancelled',
                                    cancelled: 'todo'
                                }
                                updateTaskStatus(task.id, nextStatus[task.status])
                            }}
                        >
                            {getStatusIcon(task.status)}
                        </button>
                        <span className={task.status === 'done' ? styles.completed : ''}>
                            {task.text}
                        </span>
                    </div>
                ))}
            </div>
        </div>
    )
}

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.

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.

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

Bullet Journal

S
M
T
W
T
F
S
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Show Source
import { Calendar as CalendarIcon, Circle, CheckCircle2, Square, XCircle } from 'lucide-react'
import { useState } from 'react'

type Task = {
    id: string
    text: string
    status: 'todo' | 'done' | 'migrated' | 'cancelled'
}

type DayEntry = {
    date: Date
    tasks: Task[]
}

export default function Calendar() {
    const [entries, setEntries] = useState<DayEntry[]>([])
    const [selectedDate, setSelectedDate] = useState<Date>(new Date())
    const [newTask, setNewTask] = useState('')

    const addTask = () => {
        if (!newTask.trim()) return

        setEntries(prev => {
            const existingEntry = prev.find(entry =>
                entry.date.toDateString() === selectedDate.toDateString()
            )

            if (existingEntry) {
                return prev.map(entry =>
                    entry.date.toDateString() === selectedDate.toDateString()
                        ? {
                            ...entry,
                            tasks: [...entry.tasks, {
                                id: crypto.randomUUID(),
                                text: newTask,
                                status: 'todo'
                            }]
                        }
                        : entry
                )
            }

            return [...prev, {
                date: selectedDate,
                tasks: [{
                    id: crypto.randomUUID(),
                    text: newTask,
                    status: 'todo'
                }]
            }]
        })

        setNewTask('')
    }

    const updateTaskStatus = (taskId: string, status: Task['status']) => {
        setEntries(prev =>
            prev.map(entry => ({
                ...entry,
                tasks: entry.tasks.map(task =>
                    task.id === taskId ? { ...task, status } : task
                )
            }))
        )
    }

    const getStatusIcon = (status: Task['status']) => {
        switch (status) {
            case 'todo': return <Circle className="w-4 h-4 md:w-5 md:h-5 text-gray-600" />
            case 'done': return <CheckCircle2 className="w-4 h-4 md:w-5 md:h-5 text-green-600" />
            case 'migrated': return <Square className="w-4 h-4 md:w-5 md:h-5 text-blue-600" />
            case 'cancelled': return <XCircle className="w-4 h-4 md:w-5 md:h-5 text-red-600" />
        }
    }

    const currentEntry = entries.find(entry =>
        entry.date.toDateString() === selectedDate.toDateString()
    )

    const getDaysInMonth = (date: Date) => {
        const year = date.getFullYear()
        const month = date.getMonth()
        const firstDay = new Date(year, month, 1)
        const lastDay = new Date(year, month + 1, 0)
        const days = []

        for (let i = 0; i < firstDay.getDay(); i++) {
            days.push(null)
        }

        for (let i = 1; i <= lastDay.getDate(); i++) {
            days.push(new Date(year, month, i))
        }

        return days
    }

    const getTaskStats = (date: Date) => {
        const entry = entries.find(e => e.date.toDateString() === date.toDateString())
        if (!entry) return null

        const stats = {
            todo: 0,
            done: 0,
            migrated: 0,
            cancelled: 0
        }

        entry.tasks.forEach(task => {
            stats[task.status]++
        })

        return stats
    }

    return (
        <div className="w-full max-w-4xl mx-auto p-4 md:p-8 bg-white rounded-lg shadow-md">
            <div className="flex items-center gap-2 md:gap-4 mb-6 md:mb-8">
                <CalendarIcon className="w-5 h-5 md:w-6 md:h-6 text-gray-600" />
                <h1 className="text-xl md:text-2xl font-semibold">Bullet Journal</h1>
            </div>

            <div className="grid grid-cols-7 gap-1 mb-6 md:mb-8 text-center text-sm md:text-base">
                {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(day => (
                    <div key={day} className="font-semibold py-1 md:py-2">{day}</div>
                ))}
                {getDaysInMonth(selectedDate).map((date, i) => (
                    <div
                        key={i}
                        className={`p-1 md:p-2 min-h-[60px] md:min-h-[100px] border ${date?.toDateString() === selectedDate.toDateString() ? 'border-blue-500' : 'border-gray-200'} rounded-md ${date ? 'cursor-pointer hover:bg-gray-50' : ''}`}
                        onClick={() => date && setSelectedDate(date)}
                    >
                        {date && (
                            <>
                                <div className="text-right text-gray-600 text-xs md:text-base">{date.getDate()}</div>
                                {getTaskStats(date) && (
                                    <div className="mt-1 text-[10px] md:text-xs space-y-0.5 md:space-y-1">
                                        {Object.entries(getTaskStats(date)!).map(([status, count]) => count > 0 && (
                                            <div key={status} className="flex items-center justify-between">
                                                {getStatusIcon(status as Task['status'])}
                                                <span>{count}</span>
                                            </div>
                                        ))}
                                    </div>
                                )}
                            </>
                        )}
                    </div>
                ))}
            </div>

            <div className="flex flex-col md:flex-row gap-2 md:gap-4 mb-6 md:mb-8">
                <input
                    type="text"
                    value={newTask}
                    onChange={e => setNewTask(e.target.value)}
                    placeholder="Add new task..."
                    onKeyPress={e => e.key === 'Enter' && addTask()}
                    className="flex-1 px-3 md:px-4 py-2 text-sm md:text-base border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                />
                <button
                    onClick={addTask}
                    className="px-4 py-2 bg-blue-600 text-white text-sm md:text-base rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
                >
                    Add
                </button>
            </div>

            <div className="space-y-2">
                {currentEntry?.tasks.map(task => (
                    <div key={task.id} className="flex items-center gap-2 p-2 border border-gray-200 rounded-md text-sm md:text-base">
                        <button
                            onClick={() => {
                                const nextStatus: Record<Task['status'], Task['status']> = {
                                    todo: 'done',
                                    done: 'migrated',
                                    migrated: 'cancelled',
                                    cancelled: 'todo'
                                }
                                updateTaskStatus(task.id, nextStatus[task.status])
                            }}
                            className="focus:outline-none"
                        >
                            {getStatusIcon(task.status)}
                        </button>
                        <span className={task.status === 'done' ? 'line-through text-gray-500' : ''}>
                            {task.text}
                        </span>
                    </div>
                ))}
            </div>
        </div>
    )
}

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

// ... existing imports ...
import { eachDayOfInterval, endOfMonth, format, startOfMonth, isSameDay } from 'date-fns'

export default function Calendar() {
    // ... existing state ...

and

/* ... existing styles ... */

.calendar {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 4px;
    margin-bottom: 2rem;
}

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

import { eachDayOfInterval, endOfMonth, format, startOfMonth, isSameDay } from 'date-fns'

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.

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

Bullet Journal
Show Source
import { Circle, CheckCircle2, Square, XCircle } from 'lucide-react'
import { useState } from 'react'

type Task = {
    id: string
    text: string
    status: 'todo' | 'done' | 'migrated' | 'cancelled'
}

const styles = {
    container: {
        maxWidth: '800px',
        margin: '1rem auto',
        padding: '1rem',
        width: '95%',
        backgroundColor: 'white',
        borderRadius: '8px',
        boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
        '@media (min-width: 640px)': {
            margin: '2rem auto',
            padding: '2rem'
        }
    },
    header: {
        marginBottom: '2rem',
        textAlign: 'center' as const,
        fontSize: '1.25rem',
        fontWeight: 'bold',
        '@media (min-width: 640px)': {
            fontSize: '1.5rem'
        }
    },
    taskInput: {
        display: 'flex',
        flexDirection: 'column' as const,
        gap: '0.5rem',
        marginBottom: '2rem',
        '@media (min-width: 640px)': {
            flexDirection: 'row' as const,
            gap: '1rem'
        }
    },
    input: {
        width: '100%',
        padding: '0.5rem',
        border: '1px solid #ddd',
        borderRadius: '4px',
        fontSize: '1rem'
    },
    button: {
        width: '100%',
        padding: '0.5rem 1rem',
        background: '#333',
        color: 'white',
        border: 'none',
        borderRadius: '4px',
        cursor: 'pointer',
        '@media (min-width: 640px)': {
            width: 'auto'
        }
    },
    taskList: {
        display: 'flex',
        flexDirection: 'column' as const,
        gap: '0.5rem'
    },
    task: {
        display: 'flex',
        alignItems: 'center',
        gap: '0.5rem',
        padding: '0.5rem',
        border: '1px solid #eee',
        borderRadius: '4px',
        wordBreak: 'break-word' as const
    },
    statusButton: {
        background: 'none',
        border: 'none',
        padding: 0,
        cursor: 'pointer',
        display: 'flex',
        alignItems: 'center',
        flexShrink: 0
    }
}

export default function Calendar() {
    const [tasks, setTasks] = useState<Task[]>([])
    const [newTask, setNewTask] = useState('')

    const addTask = () => {
        if (!newTask.trim()) return

        setTasks(prev => [...prev, {
            id: crypto.randomUUID(),
            text: newTask,
            status: 'todo'
        }])
        setNewTask('')
    }

    const updateTaskStatus = (taskId: string, status: Task['status']) => {
        setTasks(prev =>
            prev.map(task =>
                task.id === taskId ? { ...task, status } : task
            )
        )
    }

    const getStatusIcon = (status: Task['status']) => {
        switch (status) {
            case 'todo': return <Circle className="w-5 h-5" style={{ color: '#666' }} />
            case 'done': return <CheckCircle2 className="w-5 h-5" style={{ color: '#22c55e' }} />
            case 'migrated': return <Square className="w-5 h-5" style={{ color: '#3b82f6' }} />
            case 'cancelled': return <XCircle className="w-5 h-5" style={{ color: '#ef4444' }} />
        }
    }

    return (
        <div style={styles.container}>
            <meta name="viewport" content="width=device-width, initial-scale=1.0" />
            <div style={styles.header}>Bullet Journal</div>

            <div style={styles.taskInput}>
                <input
                    type="text"
                    value={newTask}
                    onChange={e => setNewTask(e.target.value)}
                    placeholder="Add new task..."
                    onKeyPress={e => e.key === 'Enter' && addTask()}
                    style={styles.input}
                />
                <button onClick={addTask} style={styles.button}>
                    Add
                </button>
            </div>

            <div style={styles.taskList}>
                {tasks.map(task => (
                    <div key={task.id} style={styles.task}>
                        <button
                            onClick={() => {
                                const nextStatus: Record<Task['status'], Task['status']> = {
                                    todo: 'done',
                                    done: 'migrated',
                                    migrated: 'cancelled',
                                    cancelled: 'todo'
                                }
                                updateTaskStatus(task.id, nextStatus[task.status])
                            }}
                            style={styles.statusButton}
                        >
                            {getStatusIcon(task.status)}
                        </button>
                        <span style={task.status === 'done' ? { textDecoration: 'line-through', color: '#999' } : {}}>
                            {task.text}
                        </span>
                    </div>
                ))}
            </div>
        </div>
    )
}

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

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

Bullet Journal
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Show Source
import { Circle, CheckCircle2, Square, XCircle } from 'lucide-react'
import { useState, useMemo } from 'react'

type Task = {
    id: string
    text: string
    status: 'todo' | 'done' | 'migrated' | 'cancelled'
    date: string
}

const styles = {
    container: {
        width: '100%',
        maxWidth: '90ch',
        margin: '0 auto',
        padding: '1rem',
        backgroundColor: 'white',
        borderRadius: '8px',
        boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
        '@media (min-width: 640px)': {
            padding: '2rem'
        }
    },
    header: {
        marginBottom: '2rem',
        textAlign: 'center' as const,
        fontSize: '1.25rem',
        fontWeight: 'bold',
        '@media (min-width: 640px)': {
            fontSize: '1.5rem'
        }
    },
    taskInput: {
        display: 'flex',
        flexDirection: 'column' as const,
        gap: '0.5rem',
        marginBottom: '2rem',
        '@media (min-width: 640px)': {
            gap: '1rem'
        }
    },
    input: {
        width: '100%',
        padding: '0.5rem',
        border: '1px solid #ddd',
        borderRadius: '4px',
        fontSize: '1rem'
    },
    button: {
        padding: '0.5rem 1rem',
        background: '#333',
        color: 'white',
        border: 'none',
        borderRadius: '4px',
        cursor: 'pointer',
        width: '100%',
        '@media (min-width: 640px)': {
            width: 'auto'
        }
    },
    taskList: {
        display: 'flex',
        flexDirection: 'column' as const,
        gap: '0.5rem',
        marginBottom: '2rem'
    },
    task: {
        display: 'flex',
        alignItems: 'center',
        gap: '0.5rem',
        padding: '0.5rem',
        border: '1px solid #eee',
        borderRadius: '4px',
        wordBreak: 'break-word' as const
    },
    statusButton: {
        background: 'none',
        border: 'none',
        padding: 0,
        cursor: 'pointer',
        display: 'flex',
        alignItems: 'center',
        flexShrink: 0
    },
    calendar: {
        display: 'grid',
        gridTemplateColumns: 'repeat(7, 1fr)',
        gap: '2px',
        padding: '0.5rem',
        '@media (min-width: 640px)': {
            gap: '4px',
            padding: '1rem'
        }
    },
    calendarDay: {
        padding: '0.25rem',
        border: '1px solid #eee',
        borderRadius: '4px',
        minHeight: '60px',
        cursor: 'pointer',
        '@media (min-width: 640px)': {
            padding: '0.5rem',
            minHeight: '80px'
        }
    },
    dayHeader: {
        fontSize: '0.75rem',
        marginBottom: '0.25rem',
        color: '#666',
        '@media (min-width: 640px)': {
            fontSize: '0.875rem',
            marginBottom: '0.5rem'
        }
    },
    stats: {
        fontSize: '0.625rem',
        display: 'flex',
        flexDirection: 'column' as const,
        gap: '1px',
        '@media (min-width: 640px)': {
            fontSize: '0.75rem',
            gap: '2px'
        }
    }
}

export default function Calendar() {
    const [tasks, setTasks] = useState<Task[]>([])
    const [newTask, setNewTask] = useState('')
    const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])

    const addTask = () => {
        if (!newTask.trim()) return

        setTasks(prev => [...prev, {
            id: crypto.randomUUID(),
            text: newTask,
            status: 'todo',
            date: selectedDate
        }])
        setNewTask('')
    }

    const updateTaskStatus = (taskId: string, status: Task['status']) => {
        setTasks(prev =>
            prev.map(task =>
                task.id === taskId ? { ...task, status } : task
            )
        )
    }

    const getStatusIcon = (status: Task['status']) => {
        switch (status) {
            case 'todo': return <Circle className="w-4 h-4 sm:w-5 sm:h-5" style={{ color: '#666' }} />
            case 'done': return <CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5" style={{ color: '#22c55e' }} />
            case 'migrated': return <Square className="w-4 h-4 sm:w-5 sm:h-5" style={{ color: '#3b82f6' }} />
            case 'cancelled': return <XCircle className="w-4 h-4 sm:w-5 sm:h-5" style={{ color: '#ef4444' }} />
        }
    }

    const calendarDays = useMemo(() => {
        const today = new Date()
        const start = new Date(today.getFullYear(), today.getMonth(), 1)
        const end = new Date(today.getFullYear(), today.getMonth() + 1, 0)
        const days = []

        for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
            days.push(new Date(d))
        }

        return days
    }, [])

    const getTaskStats = (date: Date) => {
        const dateStr = date.toISOString().split('T')[0]
        const dayTasks = tasks.filter(t => t.date === dateStr)

        return {
            todo: dayTasks.filter(t => t.status === 'todo').length,
            done: dayTasks.filter(t => t.status === 'done').length,
            migrated: dayTasks.filter(t => t.status === 'migrated').length,
            cancelled: dayTasks.filter(t => t.status === 'cancelled').length
        }
    }

    return (
        <div style={styles.container}>
            <div style={styles.header}>Bullet Journal</div>

            <div style={styles.taskInput}>
                <input
                    type="date"
                    value={selectedDate}
                    onChange={e => setSelectedDate(e.target.value)}
                    style={styles.input}
                />
                <input
                    type="text"
                    value={newTask}
                    onChange={e => setNewTask(e.target.value)}
                    placeholder="Add new task..."
                    onKeyPress={e => e.key === 'Enter' && addTask()}
                    style={styles.input}
                />
                <button onClick={addTask} style={styles.button}>
                    Add
                </button>
            </div>

            <div style={styles.taskList}>
                {tasks
                    .filter(task => task.date === selectedDate)
                    .map(task => (
                        <div key={task.id} style={styles.task}>
                            <button
                                onClick={() => {
                                    const nextStatus: Record<Task['status'], Task['status']> = {
                                        todo: 'done',
                                        done: 'migrated',
                                        migrated: 'cancelled',
                                        cancelled: 'todo'
                                    }
                                    updateTaskStatus(task.id, nextStatus[task.status])
                                }}
                                style={styles.statusButton}
                            >
                                {getStatusIcon(task.status)}
                            </button>
                            <span style={task.status === 'done' ? { textDecoration: 'line-through', color: '#999' } : {}}>
                                {task.text}
                            </span>
                        </div>
                    ))}
            </div>

            <div style={styles.calendar}>
                {calendarDays.map(day => {
                    const stats = getTaskStats(day)
                    return (
                        <div
                            key={day.toISOString()}
                            style={{
                                ...styles.calendarDay,
                                backgroundColor: day.toISOString().split('T')[0] === selectedDate ? '#f0f0f0' : 'white'
                            }}
                            onClick={() => setSelectedDate(day.toISOString().split('T')[0])}
                        >
                            <div style={styles.dayHeader}>{day.getDate()}</div>
                            <div style={styles.stats}>
                                {stats.todo > 0 && <div style={{ color: '#666' }}>○ {stats.todo}</div>}
                                {stats.done > 0 && <div style={{ color: '#22c55e' }}>✓ {stats.done}</div>}
                                {stats.migrated > 0 && <div style={{ color: '#3b82f6' }}>□ {stats.migrated}</div>}
                                {stats.cancelled > 0 && <div style={{ color: '#ef4444' }}{stats.cancelled}</div>}
                            </div>
                        </div>
                    )
                })}
            </div>
        </div>
    )
}

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

import { useState, useMemo } from 'react'
// other imports

const styles = {
    // 1. styles
}

export default function Calendar() {
    // 2. functions and logic

    return (
        <div style={styles.container}>
            {
                // 3. logic and markup
            }
        </div>
    )
}

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:

diff --git a/src/content/notes/2025/llm-tailwind-react/components/CssCalendar.v1.tsx b/src/content/notes/2025/llm-tailwind-react/components/CssCalendar.v2.tsx
index 2b34248..e177c5a 100644
--- a/src/content/notes/2025/llm-tailwind-react/components/CssCalendar.v1.tsx
+++ b/src/content/notes/2025/llm-tailwind-react/components/CssCalendar.v2.tsx
@@ -1,15 +1,16 @@
 import { Circle, CheckCircle2, Square, XCircle } from 'lucide-react'
-import { useState } from 'react'
+import { useState, useMemo } from 'react'

 type Task = {
     id: string
     text: string
     status: 'todo' | 'done' | 'migrated' | 'cancelled'
+    date: string
 }

 const styles = {
     container: {
-        maxWidth: '600px',
+        maxWidth: '800px',
         margin: '0 auto',
         padding: '2rem',
         backgroundColor: 'white',
@@ -45,7 +46,8 @@ const styles = {
     taskList: {
         display: 'flex',
         flexDirection: 'column' as const,
-        gap: '0.5rem'
+        gap: '0.5rem',
+        marginBottom: '2rem'
     },
     task: {
         display: 'flex',
@@ -62,12 +64,36 @@ const styles = {
         cursor: 'pointer',
         display: 'flex',
         alignItems: 'center'
+    },
+    calendar: {
+        display: 'grid',
+        gridTemplateColumns: 'repeat(7, 1fr)',
+        gap: '4px',
+        padding: '1rem'
+    },
+    calendarDay: {
+        padding: '0.5rem',
+        border: '1px solid #eee',
+        borderRadius: '4px',
+        minHeight: '80px'
+    },
+    dayHeader: {
+        fontSize: '0.875rem',
+        marginBottom: '0.5rem',
+        color: '#666'
+    },
+    stats: {
+        fontSize: '0.75rem',
+        display: 'flex',
+        flexDirection: 'column' as const,
+        gap: '2px'
     }
 }

 export default function Calendar() {
     const [tasks, setTasks] = useState<Task[]>([])
     const [newTask, setNewTask] = useState('')
+    const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])

     const addTask = () => {
         if (!newTask.trim()) return
@@ -75,7 +101,8 @@ export default function Calendar() {
         setTasks(prev => [...prev, {
             id: crypto.randomUUID(),
             text: newTask,
-            status: 'todo'
+            status: 'todo',
+            date: selectedDate
         }])
         setNewTask('')
     }
@@ -97,11 +124,42 @@ export default function Calendar() {
         }
     }

+    const calendarDays = useMemo(() => {
+        const today = new Date()
+        const start = new Date(today.getFullYear(), today.getMonth(), 1)
+        const end = new Date(today.getFullYear(), today.getMonth() + 1, 0)
+        const days = []
+
+        for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
+            days.push(new Date(d))
+        }
+
+        return days
+    }, [])
+
+    const getTaskStats = (date: Date) => {
+        const dateStr = date.toISOString().split('T')[0]
+        const dayTasks = tasks.filter(t => t.date === dateStr)
+
+        return {
+            todo: dayTasks.filter(t => t.status === 'todo').length,
+            done: dayTasks.filter(t => t.status === 'done').length,
+            migrated: dayTasks.filter(t => t.status === 'migrated').length,
+            cancelled: dayTasks.filter(t => t.status === 'cancelled').length
+        }
+    }
+
     return (
         <div style={styles.container}>
             <div style={styles.header}>Bullet Journal</div>

             <div style={styles.taskInput}>
+                <input
+                    type="date"
+                    value={selectedDate}
+                    onChange={e => setSelectedDate(e.target.value)}
+                    style={{ ...styles.input, flex: '0 0 auto' }}
+                />
                 <input
                     type="text"
                     value={newTask}
@@ -116,27 +174,53 @@ export default function Calendar() {
             </div>

             <div style={styles.taskList}>
-                {tasks.map(task => (
-                    <div key={task.id} style={styles.task}>
-                        <button
-                            onClick={() => {
-                                const nextStatus: Record<Task['status'], Task['status']> = {
-                                    todo: 'done',
-                                    done: 'migrated',
-                                    migrated: 'cancelled',
-                                    cancelled: 'todo'
-                                }
-                                updateTaskStatus(task.id, nextStatus[task.status])
+                {tasks
+                    .filter(task => task.date === selectedDate)
+                    .map(task => (
+                        <div key={task.id} style={styles.task}>
+                            <button
+                                onClick={() => {
+                                    const nextStatus: Record<Task['status'], Task['status']> = {
+                                        todo: 'done',
+                                        done: 'migrated',
+                                        migrated: 'cancelled',
+                                        cancelled: 'todo'
+                                    }
+                                    updateTaskStatus(task.id, nextStatus[task.status])
+                                }}
+                                style={styles.statusButton}
+                            >
+                                {getStatusIcon(task.status)}
+                            </button>
+                            <span style={task.status === 'done' ? { textDecoration: 'line-through', color: '#999' } : {}}>
+                                {task.text}
+                            </span>
+                        </div>
+                    ))}
+            </div>
+
+            <div style={styles.calendar}>
+                {calendarDays.map(day => {
+                    const stats = getTaskStats(day)
+                    return (
+                        <div
+                            key={day.toISOString()}
+                            style={{
+                                ...styles.calendarDay,
+                                backgroundColor: day.toISOString().split('T')[0] === selectedDate ? '#f0f0f0' : 'white'
                             }}
-                            style={styles.statusButton}
+                            onClick={() => setSelectedDate(day.toISOString().split('T')[0])}
                         >
-                            {getStatusIcon(task.status)}
-                        </button>
-                        <span style={task.status === 'done' ? { textDecoration: 'line-through', color: '#999' } : {}}>
-                            {task.text}
-                        </span>
-                    </div>
-                ))}
+                            <div style={styles.dayHeader}>{day.getDate()}</div>
+                            <div style={styles.stats}>
+                                {stats.todo > 0 && <div style={{ color: '#666' }}>○ {stats.todo}</div>}
+                                {stats.done > 0 && <div style={{ color: '#22c55e' }}>✓ {stats.done}</div>}
+                                {stats.migrated > 0 && <div style={{ color: '#3b82f6' }}>□ {stats.migrated}</div>}
+                                {stats.cancelled > 0 && <div style={{ color: '#ef4444' }}>× {stats.cancelled}</div>}
+                            </div>
+                        </div>
+                    )
+                })}
             </div>
         </div>
     )

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

diff --git a/src/content/notes/2025/llm-tailwind-react/components/TailwindCalendar.v1.tsx b/src/content/notes/2025/llm-tailwind-react/components/TailwindCalendar.v2.tsx
index 16574eb..d4767ff 100644
--- a/src/content/notes/2025/llm-tailwind-react/components/TailwindCalendar.v1.tsx
+++ b/src/content/notes/2025/llm-tailwind-react/components/TailwindCalendar.v2.tsx
@@ -77,20 +77,76 @@ export default function Calendar() {
         entry.date.toDateString() === selectedDate.toDateString()
     )

+    const getDaysInMonth = (date: Date) => {
+        const year = date.getFullYear()
+        const month = date.getMonth()
+        const firstDay = new Date(year, month, 1)
+        const lastDay = new Date(year, month + 1, 0)
+        const days = []
+
+        for (let i = 0; i < firstDay.getDay(); i++) {
+            days.push(null)
+        }
+
+        for (let i = 1; i <= lastDay.getDate(); i++) {
+            days.push(new Date(year, month, i))
+        }
+
+        return days
+    }
+
+    const getTaskStats = (date: Date) => {
+        const entry = entries.find(e => e.date.toDateString() === date.toDateString())
+        if (!entry) return null
+
+        const stats = {
+            todo: 0,
+            done: 0,
+            migrated: 0,
+            cancelled: 0
+        }
+
+        entry.tasks.forEach(task => {
+            stats[task.status]++
+        })
+
+        return stats
+    }
+
     return (
-        <div className="max-w-2xl mx-auto p-8 bg-white rounded-lg shadow-md">
+        <div className="max-w-4xl mx-auto p-8 bg-white rounded-lg shadow-md">
             <div className="flex items-center gap-4 mb-8">
                 <CalendarIcon className="w-6 h-6 text-gray-600" />
                 <h1 className="text-2xl font-semibold">Bullet Journal</h1>
             </div>

-            <div className="mb-8">
-                <input
-                    type="date"
-                    value={selectedDate.toISOString().split('T')[0]}
-                    onChange={e => setSelectedDate(new Date(e.target.value))}
-                    className="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
-                />
+            <div className="grid grid-cols-7 gap-1 mb-8 text-center">
+                {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
+                    <div key={day} className="font-semibold py-2">{day}</div>
+                ))}
+                {getDaysInMonth(selectedDate).map((date, i) => (
+                    <div
+                        key={i}
+                        className={`p-2 min-h-[100px] border ${date?.toDateString() === selectedDate.toDateString() ? 'border-blue-500' : 'border-gray-200'} rounded-md ${date ? 'cursor-pointer hover:bg-gray-50' : ''}`}
+                        onClick={() => date && setSelectedDate(date)}
+                    >
+                        {date && (
+                            <>
+                                <div className="text-right text-gray-600">{date.getDate()}</div>
+                                {getTaskStats(date) && (
+                                    <div className="mt-1 text-xs space-y-1">
+                                        {Object.entries(getTaskStats(date)!).map(([status, count]) => count > 0 && (
+                                            <div key={status} className="flex items-center justify-between">
+                                                {getStatusIcon(status as Task['status'])}
+                                                <span>{count}</span>
+                                            </div>
+                                        ))}
+                                    </div>
+                                )}
+                            </>
+                        )}
+                    </div>
+                ))}
             </div>

             <div className="flex gap-4 mb-8">

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

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 <whatever>.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.