Design Systems for AI
Back to Blog
Design10 min readDecember 18, 2025 at 18:23

Design Systems for AI

How do you design a UI for a non-deterministic system? Trust indicators, graceful failure states, and human-in-the-loop patterns are key to building AI interfaces users actually trust.

Yuval Avidani

Yuval Avidani

Author

Designing for Uncertainty

When you design a standard form, you know exactly what can go wrong. A field is empty. An email is invalid. A number is out of range. The error states are finite, predictable, and testable.

When you design for an AI Agent, you have no idea what it might output:

  • It might hallucinate facts with complete confidence

  • It might refuse to answer for opaque reasons

  • It might write a poem instead of JSON

  • It might loop endlessly attempting and failing

  • It might produce something 90% correct with a critical error in the remaining 10%

This matters to all of us building AI-powered products because the interface is the trust surface. Users can't see the model, can't understand the inference, can't verify the training data. They experience the UI. If your interface fails to communicate uncertainty, handle failures gracefully, or enable correction, even a capable AI becomes unusable.

The Trust Battery Concept

Think of user trust as a battery. Every successful interaction charges it. Every unexpected failure, hallucination, or confusing response drains it. Your UI design directly controls the charging and draining rates.

Trust Chargers:

  • Accurate responses that meet expectations

  • Clear communication of limitations

  • Easy correction when AI is wrong

  • Consistent behavior over time

  • Transparency about what the AI can and cannot do

Trust Drainers:

  • Confident-sounding hallucinations

  • Unexplained failures or refusals

  • Inconsistent behavior for similar inputs

  • Hidden limitations that surprise users

  • Inability to correct or guide the AI

Your interface design determines how gracefully you handle both scenarios.

Core Pattern 1: Streaming UI for Transparency

Streaming responses serve multiple purposes beyond perceived speed:

Show Thinking, Not Just Results

function AIResponse({ stream }: { stream: AsyncIterable }) {
  const [content, setContent] = useState('');
  const [status, setStatus] = useState<'thinking' | 'generating' | 'done'>('thinking');

useEffect(() => {
async function consume() {
setStatus('thinking');

// Show thinking indicator
await delay(500);
setStatus('generating');

for await (const chunk of stream) {
setContent(prev => prev + chunk);
}

setStatus('done');
}
consume();
}, [stream]);

return (


{status === 'thinking' && (


Analyzing your request...

)}



{status === 'generating' && }

{status === 'done' && (






)}

);
}

Why this matters:

  • "Thinking" phase sets expectations (not instant)

  • Character-by-character generation shows active work

  • Users can read while generation happens

  • Clear transition to "done" state with action options

Progressive Enhancement

Stream additional context after the main response:

function EnhancedResponse({ response, citations }) {
  return (
    
{/ Main content streams first /}

{/ Citations load after main content /}
}>

{/ Related suggestions load last /}
}>



);
}

Users get the answer immediately, with supporting information loading progressively.

Core Pattern 2: User Correction as First-Class Feature

AI will be wrong. Design for easy correction, not as an edge case but as a core workflow.

Inline Editing

function EditableAIOutput({ content, onUpdate }) {
  const [isEditing, setIsEditing] = useState(false);
  const [editedContent, setEditedContent] = useState(content);

const handleSave = async () => {
await onUpdate(editedContent);
setIsEditing(false);

// Track correction for model improvement
trackCorrection({
original: content,
corrected: editedContent,
type: 'user_edit'
});
};

return (


{isEditing ? (

value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
/>





) : (

{content}

className="edit-trigger"
onClick={() => setIsEditing(true)}
>
Edit


)}

);
}

Partial Acceptance

Let users accept parts of a response and regenerate others:

function SegmentedResponse({ segments }) {
  const [acceptedSegments, setAcceptedSegments] = useState({});

return (


{segments.map((segment, i) => (
key={i}
className={segment ${acceptedSegments[i] ? 'accepted' : ''}}
>
{segment.content}







))}

);
}

Feedback Loops

Every AI interaction should offer feedback options:

function FeedbackButtons({ responseId }) {
  const [feedback, setFeedback] = useState(null);

const submitFeedback = async (type: 'good' | 'bad', details?: string) => {
await api.submitFeedback({ responseId, type, details });
setFeedback(type);
};

if (feedback) {
return Thanks for your feedback!;
}

return (


onClick={() => submitFeedback('good')}
aria-label="This was helpful"
>


onClick={() => openDetailedFeedback('bad')}
aria-label="This wasn't helpful"
>



);
}

Negative feedback should prompt for more detail - what was wrong helps improve the system.

Core Pattern 3: Citation and Source Display

Grounding AI responses in verifiable sources builds trust and enables verification.

Inline Citations

function CitedContent({ content, citations }) {
  const citedContent = parseContentWithCitations(content);

return (


{citedContent.map((segment, i) => (
segment.type === 'text' ? (
{segment.content}
) : (
key={i}
citation={citations[segment.citationId]}
/>
)
))}


Sources


{citations.map((citation, i) => (

))}


);
}

function CitationReference({ citation }) {
const [showPreview, setShowPreview] = useState(false);

return (
className="citation-ref"
onMouseEnter={() => setShowPreview(true)}
onMouseLeave={() => setShowPreview(false)}
>
[{citation.id}]
{showPreview && (


{citation.title}

{citation.excerpt}



View source →


)}

);
}

Confidence Indicators

When appropriate, show how confident the AI is:

function ConfidenceIndicator({ confidence }: { confidence: number }) {
  const level =
    confidence > 0.9 ? 'high' :
    confidence > 0.7 ? 'medium' :
    confidence > 0.5 ? 'low' : 'very-low';

const labels = {
'high': 'High confidence',
'medium': 'Moderate confidence - verify important details',
'low': 'Low confidence - please verify',
'very-low': 'Very uncertain - treat as starting point only'
};

return (

confidence-indicator ${level}}>

{labels[level]}

);
}

Core Pattern 4: Graceful Failure States

AI fails in unique ways. Design for each failure mode:

Refusal Handling

function RefusalState({ reason, alternatives }) {
  return (
    
I can't help with this request

{reason}

{alternatives.length > 0 && (


Here are some things I can help with instead:



    {alternatives.map((alt, i) => (



  • ))}


)}



);
}

Hallucination Warnings

When the AI generates information that can't be verified:

function UnverifiedContent({ content, canVerify }) {
  return (
    
{!canVerify && (
This response includes information I couldn't verify. Please double-check important facts.
)}

{content}


Was this information accurate?




);
}

Timeout and Error States

function AIError({ error, onRetry }) {
  const errorMessages = {
    'timeout': {
      title: 'This is taking longer than expected',
      message: 'The request is still processing. You can wait or try again.',
      actions: ['wait', 'retry', 'simplify']
    },
    'overloaded': {
      title: 'High demand right now',
      message: 'Please try again in a moment.',
      actions: ['retry']
    },
    'context_too_long': {
      title: 'Too much information at once',
      message: 'Try breaking your request into smaller parts.',
      actions: ['simplify']
    },
    'unknown': {
      title: 'Something went wrong',
      message: 'We encountered an unexpected issue.',
      actions: ['retry', 'contact_support']
    }
  };

const { title, message, actions } = errorMessages[error.type] || errorMessages.unknown;

return (




{title}



{message}



{actions.includes('retry') && (

)}
{actions.includes('simplify') && (

)}
{actions.includes('wait') && (

)}


);
}

Core Pattern 5: Human-in-the-Loop Workflows

For high-stakes AI actions, build approval workflows:

Preview and Confirm

function AIActionPreview({ action, onConfirm, onCancel }) {
  return (
    
Review before executing


The AI wants to:



{action.description}

This will affect:

    {action.affectedItems.map((item, i) => (
  • {item}
  • ))}

{action.reversible ? (

This can be undone

) : (

This cannot be undone

)}


className="confirm-button"
onClick={onConfirm}
>
Confirm and execute

className="cancel-button"
onClick={onCancel}
>
Cancel




);
}

Staged Execution

For complex operations, show progress and allow intervention:

function StagedExecution({ stages }) {
  return (
    
{stages.map((stage, i) => (
stage ${stage.status}}>
{stage.name}

{stage.status === 'in_progress' && (





)}

{stage.status === 'awaiting_approval' && (


{stage.approvalMessage}





)}

{stage.status === 'completed' && stage.result && (


{stage.result.summary}


)}

))}

);
}

Design Principles for AI UX

1. Set Expectations Early

Before users interact with AI, tell them what to expect:




2. Make the AI's State Visible

Users should always know:




3. Preserve User Agency

The user is in control, not the AI:




4. Design for Error Recovery

Every failure mode needs a recovery path:




5. Build Trust Gradually

Start with low-stakes, high-visibility operations:




My Take: UX Is the Bottleneck

In my opinion, AI capability is outpacing AI UX. We have models that can do remarkable things, but interfaces that fail to communicate those capabilities, handle failures gracefully, or build user trust.

The teams that win in AI products won't just have the best models - they'll have interfaces that make those models usable, trustworthy, and delightful. Design for uncertainty. Build for correction. Communicate confidence. Show your work.

The AI can be brilliant, but if users don't trust it, don't understand it, and can't correct it - it doesn't matter. UX is the bottleneck.

Design accordingly.