Tips for getting LLMs to write good UI

Best practices for coding design systems with AI

Sam Pierce Lolla

TL;DR - Use design system best practices, context engineering, and proper CI/CD tooling

Lots of teams are vibe coding frontend. Even with component libraries like shadcn, the UI can diverge quickly and make your app feel messy and amateurish. Inconsistency is a huge hindrance to good design, and LLMs on their own don’t do design consistency well.

You can get great UI out of LLMs, but it requires some setup: namely establishing design hygiene, good tooling, and context management.

Fort the past few years, I’ve been working with Series-A startups teams to build their product UI using agents in Cursor and Claude code. Here are a few helpful lessons.

(PS - you can hire me to do this for you—details below)

Clear separation of concerns

Your UI components should live in a separate design system. These files should be adjacent to, but distinct from, the application logic. Ideally use a top-level directory, or package in a monorepo.

UI libraries ≠ design systems

You should seed your design system with a modern UI library like shadcn, Radix, Untitled, or Base UI and adapt it to your needs.

Lots of UI libraries give devs a lot of flexibility in how they’re used. Libraries like shadcn are explicitly designed to be infinitely flexible, and their APIs support flexible composition and per-component styling.

This is a problem for design systems because it allows for inconsistency. Your app doesn’t need 10 flavors of a nav sidebar—pick the one you need and only let LLMs use that one. This is basically the difference between a UI library and a design system.

Document the design system

You want both LLMs and engineers to know what components are available. You can use Storybook, but I usually just make a secret page in the app with all components . This will be useful in the steps below.

The design system. It also serves as an artist palette for engineers.

Specifically, use a markdown component index file

Make one markdown file that lists and explains all UI components. Do not require LLMs to navigate the file system to understand what components are available. This file should be small enough for any reasonable context window.

Prompt LLMs to use the design system for every feature

Use agents.md to reference your component index:

Use the design system in /design-system for all UI components. Components.md has a list of all available components. Do not write new UI components inline.

Configure brand tokens in one place

You should use proper design tokens (read: variables) for all color tokens using the preferred config method of your component library.

These components should (ideally semantic).

The idea is to define stuff like an error color once so the LLM doesn’t have to reinvent the wheel. This is good design practice, but important for LLMs to so they don’t waste tokens on writing boilerplate.

You can use a Figma extension to copy tokens right from Figma as CSS/JS and paste into your app.

Shameless plug: Spooky let’s you build a brand and gives you all major design library configs out of the box.

Avoid LLM footguns

For example, shadcn allows you to pass custom a className to components by default. LLMs tend to use this to skirt design system rules and hack components for bad one-off UI.

I suggest removing this prop altogether. Prefer semantic props like variant="destructive" over className="bg-red-500". LLMs will use whatever props are available to them, so design your component API accordingly.

This has the bonus side-effect of reducing the number of tokens your agent needs to understand and manage your.

Let’s take a look at how we might design an API for a tabs component.

Before (from the Shadcn docs):

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

export function TabsDemo() {
  return (
    <Tabs defaultValue="overview" className="w-[400px]">
      <TabsList>
        <TabsTrigger value="overview">Overview</TabsTrigger>
        <TabsTrigger value="analytics">Analytics</TabsTrigger>
        <TabsTrigger value="reports">Reports</TabsTrigger>
        <TabsTrigger value="settings">Settings</TabsTrigger>
      </TabsList>
      <TabsContent value="overview">
        <Overview />
      </TabsContent>
      <TabsContent value="analytics">
        <Analytics />
      </TabsContent>
      <TabsContent value="reports">
        <Reports />
      </TabsContent>
      <TabsContent value="settings">
        <Settings />
      </TabsContent>
    </Tabs>
  );
}

After (cleaned):

import { Tabs } from "design-system";

<Tabs
  value={currentTab}
  onValueChange={setCurrentTab}
  tabs={[
    { value: "overview", label: "Overview", content: <Overview /> },
    { value: "analytics", label: "Analytics", content: <Analytics /> },
    { value: "reports", label: "Reports", content: <Reports /> },
    { value: "settings", label: "Settings", content: <Settings /> },
  ]}
/>;

Use linters to enforce UI compliance

LLMs don’t always get it right on the first shot, but if you give them a linter they can fix their own mistakes. Linters are less opinionated and more formal than prompts—think “trust but verify.” LLMs are great at writing custom linter rules too.

Some useful lint rules to consider:

  • Always use semantic color classes
  • Always use sentence case
  • Use heading components over literal text sizing for large text
  • Never hit the network or use application logic in components.

Encourage design system usage in PR checks

I recently wrote a Greptile rule to check all PRs for non-design system code and suggest changes if the system wasn’t used.

{
  "instructions": "Use existing design-system components for UI. Do not introduce one-off UI components in application code.",
  "customContext": {
    "rules": [
      {
        "scope": ["src/**/*.tsx"],
        "rule": "Import UI from \"design-system\" whenever an equivalent component exists."
      },
      {
        "scope": ["design-system/**/*.tsx"],
        "rule": "Keep design system components pure UI. Do not implement routing, app state, or network calls."
      }
    ]
  }
}

Make adding new components easy

Encourage developers and LLMs to add new components if their use case isn’t supported in the design system yet.

But quarantine new UI

Some new components are going to be whacky, and that’s OK. Instruct non-designer devs and LLMs to add a quarantine tag until a human designer reviews it and manually removes the tag. Use the smallest governance model that works for your design system and team.

AGENTS.md

When adding new components to the design-system, always document all variations the component in components.tsx. Always add new components to the “Quarantine” section. NEVER remove a component from the section unless the user explicitly asks you to, and always confirm with the user before making the move.

Track progress visually in your app with a custom debug mode

I include a unique CSS class in all my design system component files. My linter requires this class, and prevents any code that’s outside the design system from using it.

Then, I add a secret debug mode to the app where I can toggle custom styling for the class and easily see what components are coming from the design system and which are not.


Are you struggling to make your product beautiful and easy to use? I work with Series-A startups to rebuild their design systems from the ground up. See how it works →

Don't miss any future posts:

Infrequent emails, no spam. Unsubscribe anytime.

Related
What to do when you’re stuck on a design 
Things to try when you’re out of ideas
The books I recommend to designers 
What to read when you’re learning
Design is mostly about emphasis Coming soon
11 ways to make the important stuff STAND OUT