When you spend time examining AI-generated UI, there's often an unsettling feeling: something's off.
The suspects are obvious—overly enthusiastic gradients, scattered emoji, excessive border-radius, shadow layering that feels arbitrary. These surface features certainly catch the eye. But they're not the real problem. The fundamental reason AI-generated UI carries a sense of artificiality is the absence of human judgment baked into every design decision.
Throughout Antigravity Lab, we've championed "vibe coding" as a philosophy—which at its core means injecting judgment into AI workflows. This article explores how that judgment manifests at the component implementation level: how to make UI code that doesn't just look human-designed, but reasons like one.
The Surface Symptoms of AI-Generated UI
Let's start with what our eyes register.
The "Safe" Gradient
from-indigo-500 to-purple-600. You've seen this a thousand times in AI output. It's a statistically low-risk color combination. Since AI trains on successful designs, it gravitates toward color pairings that rarely offend. Nothing fails, but nothing stands out either.
The problem isn't the gradient itself. It's that there's no reasoning behind it. Maybe your brand primary is darker. Maybe you want a monochrome-first approach with color only for CTAs. The AI simply cannot ask, "Why?"
Emoji Proliferation
🚀 🎉 ✨ 💡 🔥
AI treats emoji as a quantifiable variable in "approachability." More emoji = friendlier = better. But emoji placement and density require human judgment about your audience. Will this resonate, or will it feel hollow to your users?
Border-Radius Excess
rounded-2xl and rounded-full seem risk-free—modern, friendly, approachable. That's exactly why AI favors them. It learned they're statistically safe.
But is every button truly better as rounded-2xl? For touch targets, what matters most isn't border-radius; it's that the interactive area meets the 44px minimum for accessible finger tapping.
The Core Issue: Intentionality in Code
This is where we shift from observation to essence.
What separates thoughtfully designed UI from AI-generated UI? Human intent embedded in decisions.
When a CTA button has higher contrast than surrounding elements, it's because the designer judged that particular action as most important to the user's goal.
When error buttons have subtle border-radius, it's because there's deliberate restraint—"this is a dangerous action, give it visual weight and force the user to look carefully."
When a modal darkens the background, it's a conscious choice to eliminate distraction and focus attention.
AI has no such chain of intentional reasoning. It selects statistically "good" elements and combines them. The "why" is missing.
Three Ways to Embed Judgment in Code
Let's get practical. How do we encode human judgment into our components?
1. Express Intent Through Types
Consider a naive Button component:
// No judgment here
interface ButtonProps {
color?: string; // 'blue' | 'red' | 'green' ...
children: React.ReactNode;
}
export function Button({ color = 'blue', children }: ButtonProps) {
return (
<button className={`bg-${color}-500 text-white px-4 py-2 rounded-lg`}>
{children}
</button>
);
}This is judgment-free. <Button color="red">Delete</Button> creates a red button, but the "why" is opaque.
Now, let's encode intent into the type signature:
// Intent encoded in types
type ButtonVariant = 'primary' | 'secondary' | 'destructive' | 'ghost' | 'link';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps {
variant?: ButtonVariant; // Meaningful choices only
size?: ButtonSize;
isLoading?: boolean;
disabled?: boolean;
children: React.ReactNode;
}
export function Button({
variant = 'primary',
size = 'md',
isLoading = false,
disabled = false,
children
}: ButtonProps) {
const baseStyles = 'font-medium transition-all duration-200 focus:ring-2';
const variantStyles = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-300',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-400',
destructive: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-300',
ghost: 'bg-transparent text-gray-900 hover:bg-gray-100 focus:ring-gray-300',
link: 'bg-transparent text-blue-600 underline hover:no-underline focus:ring-blue-300',
};
const sizeStyles = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base min-h-[44px]', // Accessible touch target
lg: 'px-6 py-3 text-lg min-h-[48px]',
};
return (
<button
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${
isLoading ? 'opacity-70 cursor-wait pointer-events-none' : ''
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={disabled || isLoading}
>
{isLoading ? 'Processing...' : children}
</button>
);
}What changed:
- Variant as intent: "This is the primary action," "This is destructive," "This is supplementary"—all encoded in a constrained type
- 44px minimum in size: Accessibility judgment baked into the implementation
- isLoading behavior unified: When loading, the component exhibits a coherent behavior (wait cursor, reduced opacity, disabled state) that reflects a judgment about UX
2. Embed Brand Judgment in Design Tokens
Now let's see how design tokens carry intentionality:
// tailwind.config.ts
export default {
theme: {
colors: {
// Brand palette reflecting Antigravity's world
surface: 'oklch(0.98 0.002 0)', // Slightly warm white, not pure white
'surface-alt': 'oklch(0.94 0.002 0)',
// Semantic colors with clear roles
primary: 'oklch(0.55 0.2 260)', // Brand primary (blue-violet)
secondary: 'oklch(0.65 0.1 260)', // Brand secondary (lighter)
success: 'oklch(0.58 0.15 142)', // Intent: confirmation
warning: 'oklch(0.68 0.15 65)', // Intent: caution
error: 'oklch(0.55 0.2 25)', // Intent: danger
info: 'oklch(0.60 0.15 250)', // Intent: information
},
spacing: {
// Deliberate scale based on Fibonacci proportions
'xs': '0.25rem', // Text-level whitespace
'sm': '0.5rem', // Tight element spacing
'md': '1rem', // Standard spacing
'lg': '1.5rem', // Section spacing
'xl': '2.5rem', // Major section gaps
'xxl': '4rem', // Page margins
},
fontSize: {
// Readability judgment: size paired with line-height
'xs': ['0.75rem', { lineHeight: '1.5' }],
'sm': ['0.875rem', { lineHeight: '1.5' }],
'base': ['1rem', { lineHeight: '1.6' }],
'lg': ['1.125rem', { lineHeight: '1.6' }],
'xl': ['1.375rem', { lineHeight: '1.5' }],
'h2': ['1.75rem', { lineHeight: '1.3' }],
'h1': ['2.25rem', { lineHeight: '1.2' }],
},
},
};Notice:
- oklch color space: Closer to human perception than RGB or HSL. More intentional brand presence
- Semantic color names:
primary,error,success—the role is clear - Paired line-heights: Headings get tighter spacing (1.2), body text gets room to breathe (1.6)
3. Encode "Why" in Conditional Logic
Finally, conditional branches themselves are expressions of judgment:
// FormInput.tsx - Accessibility judgment baked into conditions
interface FormInputProps {
label: string;
value: string;
onChange: (val: string) => void;
error?: string;
isLoading?: boolean;
required?: boolean;
}
export function FormInput({
label,
value,
onChange,
error,
isLoading,
required,
}: FormInputProps) {
const inputId = `input-${label.replace(/\s/g, '-')}`;
return (
<div className="flex flex-col gap-md">
<label htmlFor={inputId} className="font-medium text-gray-900">
{label}
{required && <span className="text-error ml-1">*</span>}
</label>
<input
id={inputId}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={isLoading}
// Error state judgment:
// - Visual distinction (red border)
// - Screen reader users notified (aria-invalid)
// - Error message linked via aria-describedby
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : undefined}
className={`
px-3 py-2 border-2 rounded-md transition-colors
${error
? 'border-error bg-error/5 focus:ring-error/30'
: 'border-gray-300 focus:ring-primary/30'
}
${isLoading ? 'cursor-wait opacity-70' : ''}
focus:outline-none focus:ring-2
`}
/>
{/* Error display: role="alert" for immediate announcement */}
{error && (
<div
id={`${inputId}-error`}
role="alert"
aria-live="polite"
className="text-error text-sm font-medium"
>
{error}
</div>
)}
</div>
);
}The judgments encoded here:
- aria-invalid + aria-describedby: We judge it important that screen reader users receive exact error information
- role="alert" + aria-live="polite": We judge that error feedback should interrupt and be announced immediately
- cursor-wait: We judge that showing "the system is thinking" provides psychological reassurance
Why This Matters
All three approaches share one trait: every choice has a "why."
Button variants exist because user psychology benefits from consistent, limited choices. The 44px minimum exists because elderly users and those with larger hands deserve full accessibility. aria-live attributes exist because blind users deserve the same real-time feedback as sighted users.
AI feels "off" because this interlocking chain of intentional reasoning is absent. Conversely, code that encodes such reasoning naturally feels more human—because it was designed by humans thinking clearly.
In Vibe Coding Practice
Antigravity Lab's "vibe coding" methodology is precisely about injecting this judgment into AI generation. Here's an example:
# Prompt: Pass design tokens first
Reference the tailwind.config.ts above.
Implement a Button component with:
- variant: 'primary' | 'secondary' | 'destructive' | 'ghost' | 'link'
- size: 'sm' | 'md' | 'lg' (minimum touch target 44px)
- isLoading state: cursor-wait, opacity-70, disabled
- Full role and aria-* attributes for assistive tech
- Use ONLY colors, spacing, typography from design tokens
- For each conditional branch, add a comment explaining the UX reasoningWhen you provide AI with explicit tokens and reasoning before generation, the output quality transforms.