# Why AI coding assistants write clean-looking CSS that breaks in production (and how to fix it)

> Source: <https://dev.to/devpreflight/why-ai-coding-assistants-write-clean-looking-css-that-breaks-in-production-and-how-to-fix-it-4mk1>
> Published: 2026-09-21 21:05:40+00:00

If you use Cursor, Claude, or Copilot for frontend work, you already know the feeling.

You prompt the model to generate a dashboard card or a modal component. It spits out fifty lines of clean TypeScript and Tailwind classes in two seconds. It compiles without errors. In your local browser with sample data, it looks ready to ship.

Then you deploy it to staging or open it on your phone, and subtle UI bugs start popping up everywhere.

The layout jumps every time a button loads. Long usernames overflow their containers. Tooltips stop working on disabled buttons. Text gets clipped on smaller screens.

AI models are great at syntax, but they tend to skip defensive CSS patterns. Here are five visual bugs AI coding assistants introduce on almost every prompt, and the exact code fixes to prevent them.

When you ask an AI to add a loading state to a button, it almost always writes something like this:

```
// Typical AI code
<button disabled={isLoading} className="px-4 py-2 bg-blue-600 text-white rounded-md">
  {isLoading ? <Spinner className="w-4 h-4" /> : "Save Changes"}
</button>
```

The problem is that a 16px spinner is much narrower than the text "Save Changes". The moment the user clicks, the button width shrinks. Adjacent inputs and buttons shift across the screen, causing a jarring Cumulative Layout Shift (CLS).

Keep the original label in the DOM to lock the button width, and place the spinner in an absolute overlay:

```
// Production fix
<button disabled={isLoading} className="relative px-4 py-2 bg-blue-600 text-white rounded-md">
  <span className={isLoading ? "invisible" : "visible"}>Save Changes</span>
  {isLoading && (
    <span className="absolute inset-0 flex items-center justify-center">
      <Spinner className="w-4 h-4 animate-spin" />
    </span>
  )}
</button>
```

This keeps the button width fixed regardless of state.

AI tools love flexbox, but they rarely include defensive width bounds. When a user has a long name or uploads a file with a fifty-character title, the layout breaks.

```
// Typical AI code
<div className="flex items-center gap-3">
  <Avatar />
  <div className="flex flex-col">
    <span className="font-medium truncate">{user.name}</span>
    <span className="text-sm text-slate-500 truncate">{user.email}</span>
  </div>
</div>
```

Adding `truncate` does nothing here because flex children default to `min-width: auto`. If the container gets squeezed, the text refuses to shrink and pushes the parent element beyond the viewport.

Add `min-w-0` to the flex child holding the text:

```
// Production fix
<div className="flex items-center gap-3">
  <Avatar />
  <div className="flex flex-col min-w-0">
    <span className="font-medium truncate">{user.name}</span>
    <span className="text-sm text-slate-500 truncate">{user.email}</span>
  </div>
</div>
```

That one utility class tells the browser that the flex child is allowed to shrink below its content width, making text truncation work as expected.

When an action is blocked, you usually want a tooltip explaining why (for instance, "You need admin permissions to delete this project").

AI models almost always disable the button directly:

```
// Typical AI code
<Tooltip content="Admin permissions required">
  <button disabled={!isAdmin} className="btn-primary">
    Delete Project
  </button>
</Tooltip>
```

When HTML buttons have the native `disabled` attribute, browsers stop firing all mouse events on that element. Hovering over the button will not trigger mouseEnter, and the tooltip never renders. Users click a dead button with no idea why it is inactive.

Use `aria-disabled` and manage the interaction styles manually:

```
// Production fix
<Tooltip content={!isAdmin ? "Admin permissions required" : ""}>
  <button
    aria-disabled={!isAdmin}
    onClick={(e) => {
      if (!isAdmin) {
        e.preventDefault();
        return;
      }
      handleDelete();
    }}
    className={`btn-primary ${!isAdmin ? "opacity-50 cursor-not-allowed pointer-events-auto" : ""}`}
  >
    Delete Project
  </button>
</Tooltip>
```

The button remains keyboard and screen-reader accessible, mouse events still bubble to the tooltip, and the user gets a clear explanation.

Ask an AI model for a modal, drawer, or settings panel, and it will often set a fixed height:

```
// Typical AI code
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
  <div className="w-[500px] h-[600px] bg-white rounded-lg p-6 flex flex-col justify-between">
    <ModalHeader />
    <ModalBody />
    <ModalFooter />
  </div>
</div>
```

On a desktop monitor, 600px looks fine. On a smaller mobile screen or a laptop with browser toolbars open, the modal footer gets pushed off the bottom edge. Users cannot see or tap the submit button.

Use dynamic viewport units and internal scroll areas:

```
// Production fix
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4">
  <div className="w-full max-w-lg max-h-[90dvh] bg-white rounded-lg flex flex-col">
    <div className="p-6 border-b border-slate-200">
      <ModalHeader />
    </div>
    <div className="p-6 overflow-y-auto flex-1">
      <ModalBody />
    </div>
    <div className="p-6 border-t border-slate-200">
      <ModalFooter />
    </div>
  </div>
</div>
```

The header and footer stay pinned in view, while long form content scrolls cleanly inside `ModalBody`.

When generating edit modals, AI tools frequently store form inputs inside component state without resetting them when the target entity changes.

```
// Typical AI code
function EditUserModal({ user, isOpen, onClose }) {
  const [name, setName] = useState(user.name);
  const [role, setRole] = useState(user.role);

  if (!isOpen) return null;
  return (
    <Modal onClose={onClose}>
      <input value={name} onChange={(e) => setName(e.target.value)} />
    </Modal>
  );
}
```

If the user edits "Alice", closes the modal, and clicks edit on "Bob", the inputs will still show "Alice". The state initialized on the first mount and never refreshed.

Key the modal instance by entity ID at the call site so React tears down and remounts fresh state on entity change:

```
// Production fix at the parent call site
{activeUser && (
  <EditUserModal
    key={activeUser.id}
    user={activeUser}
    isOpen={Boolean(activeUser)}
    onClose={() => setActiveUser(null)}
  />
)}
```

Using a key boundary is cleaner and less error-prone than juggling multiple `useEffect` reset hooks inside the child component.

Fixing these edge cases prompt after prompt takes up a lot of code review time.

One way to solve this is to define strict CSS rules directly inside your `.cursorrules` or `AGENTS.md` file so the model stops reaching for fragile patterns. Another is maintaining a dedicated set of accessible UI primitives that already handle layout shifts, touch targets, and viewport bounds out of the box.

We put together an open-source collection of accessible flat UI primitives for React 19 and Next.js 16 over at [devpreflight.com](https://devpreflight.com) to save developers from rebuilding these safeguards on every project.
