Arun
UX Case Studies03 / 05

UX case study · AI design agent

Prompt to Blade UI

Type your prompt → Claude creates a JSON description of a screen → real Blade components rendered on screen. Built for Razorpay's Blade Build Challenge, in about two hours.

TypeAI design agent
ContextRazorpay AI × Design Challenge
Built inAbout two hours
Year2026
30K+Impressions
19K+Reached
593Tried it live
Prompt to Blade UI — a payment successful screen generated by Claude and rendered in Blade
Fig. 01Preview — the screen Claude generated, rendered with real Blade components
“
Autonomy bounded by the system, not by the prompt.

01The problem

By default, an LLM is a free-form text generator. Ask it to "design a payment screen" and it might hand back code with hard-coded colours like #00ff00, random pixel sizes, a layout that ignores your design system, or a component that doesn't exist. Often useful — but unpredictable. You can't guarantee it'll be on-brand.

02The technique

Don't let it write freely — make it fill a form. The Anthropic API has a feature called structured outputs: you hand the model a schema (a rulebook), and the reply is forced to match it. Claude chooses what goes in the blanks, but it can't add a blank that isn't on the form — the API rejects anything off-shape.

UI_SCHEMA is that strict form — the rulebook the reply is forced to match. Everything Claude is allowed to return is defined in it, and anything off-shape is rejected.

The constraint, not the prompt, is what keeps the output on-system.

03What's inside UI_SCHEMA

UI_SCHEMA is a component library written as rules — a fixed set of elements, each with a fixed set of props, and additionalProperties: false at every level.

ui-schema.ts37 lines
export const UI_SCHEMA = {
  type: 'object',
  additionalProperties: false,          // anything off-shape is rejected outright
  required: ['title', 'sections'],
  properties: {
    title:    { type: 'string' },
    subtitle: { type: 'string' },
    sections: { type: 'array', items: {
      additionalProperties: false,
      required: ['elements'],
      properties: {
        variant:  { enum: ['card', 'plain'] },
        layout:   { enum: ['stack', 'grid'] },
        heading:  { type: 'string' },
        elements: { type: 'array', items: { anyOf: [

          // every element Claude may place — and nothing else is valid
          { kind: 'heading',   text,  size?  small | medium | large },
          { kind: 'text',      text,  tone?  normal | subtle | muted },
          { kind: 'amount',    value, label?, currency? },
          { kind: 'statTile',  label, value, currency?, delta?, up? },
          { kind: 'badge',     text,  color? positive | negative | notice |
                                        information | neutral | primary },
          { kind: 'button',    text,  variant? primary | secondary | tertiary, icon? },
          { kind: 'input',     label, placeholder?, inputType? text | email | password | textarea },
          { kind: 'switchRow', title, description?, on? },
          { kind: 'person',    name,  subtitle? },
          { kind: 'feature',   text },
          { kind: 'divider' },

        ]}},
      },
    }},
  },
} as const;

// note: even 'color' is a semantic token — positive, notice — never a hex.

04Blade Schema Playground

Explaining a guardrail is weaker than letting someone hit one — so I built a playground. You assemble a screen the way Claude has to, by filling the fixed form; the exact JSON spec builds alongside; then you try to add a raw colour and feel additionalProperties: false push back. It runs offline, embedded in the deck.

05The renderer — Claude's JSON becomes real Blade

The validated spec never touches the DOM directly. A deterministic renderer walks it and swaps each kind for a real @razorpay/blade component — every colour, space and radius resolved through Blade tokens. An unknown kind is skipped, so schema drift can't crash the screen.

SchemaRenderer.tsx14 lines
// a validated UIScreen  →  real @razorpay/blade components
const Element = ({ el }: { el: UIElement }) => {
  switch (el.kind) {
    case 'heading':   return <Heading size={el.size ?? 'small'} color="surface.text.gray.normal">{el.text}</Heading>;
    case 'text':      return <Text color={TEXT_COLOR[el.tone ?? 'subtle']}>{el.text}</Text>;
    case 'amount':    return <Amount value={el.value} currency={el.currency ?? 'INR'} type="heading" size="large" />;
    case 'badge':     return <Badge color={el.color ?? 'neutral'} emphasis="intense">{el.text}</Badge>;
    case 'button':    return <Button variant={el.variant ?? 'primary'} icon={ICONS[el.icon]} isFullWidth>{el.text}</Button>;
    case 'input':     return el.inputType === 'password' ? <PasswordInput label={el.label} /> : <TextInput label={el.label} type={el.inputType} />;
    case 'switchRow': return <Box …><Text weight="semibold">{el.title}</Text><Switch defaultChecked={el.on} /></Box>;
    case 'divider':   return <Divider />;
    default:          return null;   // unknown kind → skipped, the screen never crashes
  }
};
The generated screen as Blade JSX
Fig. 02Code — the exact Blade JSX, ready to lift out
The design line: the model owns content and arrangement, the design system owns styling — the schema is where that line is drawn.

Next case study

04 / 05