chatgpt-appadd-widget
Add a new inline widget to your ChatGPT App with Tailwind CSS and Apps SDK integration.
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
Add Inline Widget
You are helping the user add a new inline HTML widget with Tailwind CSS to their ChatGPT App.
Widget Patterns Available
Choose from these polished widget patterns:
1. Card Grid Widget
- Modern cards with hover effects and badges
- Responsive grid layout
- Best for: task lists, product catalogs, search results
2. Stats Dashboard Widget
- Colorful stat cards with icons
- Trend indicators (up/down arrows)
- Best for: analytics, dashboards, KPIs
3. Table Widget
- Clean data table with hover rows
- Column alignment support
- Best for: data tables, reports, logs
4. Bar Chart Widget
- Animated bars with smooth transitions
- Auto-scaling height
- Best for: comparisons, distributions
5. Comparison Cards Widget
- Side-by-side cards with "recommended" badge
- Great for pricing or scenario comparison
- Best for: mortgages, plans, options
6. Timeline Widget
- Vertical timeline with dots
- Scrollable container
- Best for: schedules, amortization, history
Workflow
-
Gather Information Ask the user:
- What data will this widget display?
- What actions should users be able to take?
- Which pattern fits best?
-
Define Data Shape Create TypeScript interface for tool output.
-
Add Widget Config Add to the
widgetsarray inserver/index.ts:{ id: "my-widget", name: "My Widget", description: "Displays data visually", templateUri: "ui://widget/my-widget.html", invoking: "Loading...", invoked: "Ready", mockData: { /* sample data for preview */ }, }, -
Add Widget HTML with Tailwind Add case to
generateWidgetHtml()function using Tailwind CSS:if (widgetId === "my-widget") { return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>My Widget</title> <script src="https://cdn.tailwindcss.com"></script> ${previewScript} </head> <body class="bg-gradient-to-br from-gray-50 to-gray-100 text-gray-900 p-4 font-sans antialiased"> <div id="root"> <div class="flex items-center justify-center min-h-[200px] text-gray-400"> <svg class="animate-spin h-6 w-6 mr-2" fill="none" viewBox="0 0 24 24"> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> </svg> Loading... </div> </div> <script> (function() { let rendered = false; function render(data) { if (rendered || !data) return; rendered = true; document.getElementById('root').innerHTML = '...'; } function tryRender() { if (window.PREVIEW_DATA) { render(window.PREVIEW_DATA); return; } if (window.openai?.toolOutput) { render(window.openai.toolOutput); } } window.addEventListener('openai:set_globals', tryRender); const poll = setInterval(() => { if (window.openai?.toolOutput || window.PREVIEW_DATA) { tryRender(); clearInterval(poll); } }, 100); setTimeout(() => clearInterval(poll), 10000); tryRender(); })(); </script> </body> </html>`; } -
Create/Update Tool Add tool that returns widget data with
widgetId. -
Test Widget
npm run dev open http://localhost:3000/preview/my-widget
Tailwind Widget Patterns
Card Grid
function render(data) {
if (rendered || !data?.items) return;
rendered = true;
const statusColors = {
active: 'bg-emerald-100 text-emerald-700 ring-1 ring-emerald-600/20',
pending: 'bg-amber-100 text-amber-700 ring-1 ring-amber-600/20',
inactive: 'bg-gray-100 text-gray-600 ring-1 ring-gray-500/20',
};
document.getElementById('root').innerHTML = `
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
${data.items.map(item => `
<div class="group bg-white rounded-xl shadow-sm hover:shadow-md transition-all duration-200 border border-gray-200 hover:border-gray-300 overflow-hidden">
<div class="p-5">
<div class="flex items-start justify-between mb-3">
<h3 class="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">${item.title}</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[item.status] || statusColors.inactive}">
${item.status}
</span>
</div>
<p class="text-sm text-gray-500">${item.description || ''}</p>
</div>
</div>
`).join('')}
</div>
`;
}
Stats Dashboard
function render(data) {
if (rendered || !data?.stats) return;
rendered = true;
const colors = ['bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500'];
const fmt = n => n >= 1000 ? (n/1000).toFixed(1) + 'K' : n.toLocaleString();
document.getElementById('root').innerHTML = `
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
${data.stats.map((stat, i) => `
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md transition-shadow">
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 ${colors[i % colors.length]} rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
</svg>
</div>
<span class="text-sm font-medium text-gray-500">${stat.label}</span>
</div>
<span class="text-3xl font-bold text-gray-900">${fmt(stat.value)}</span>
</div>
`).join('')}
</div>
`;
}
Table
function render(data) {
if (rendered || !data?.rows || !data?.columns) return;
rendered = true;
document.getElementById('root').innerHTML = `
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table class="w-full">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
${data.columns.map(col => `
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">${col.label}</th>
`).join('')}
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
${data.rows.map(row => `
<tr class="hover:bg-gray-50 transition-colors">
${data.columns.map(col => `
<td class="px-4 py-3 text-sm text-gray-700">${row[col.key] ?? '—'}</td>
`).join('')}
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
Bar Chart
function render(data) {
if (rendered || !data?.bars) return;
rendered = true;
const max = Math.max(...data.bars.map(b => b.value));
const colors = ['bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500'];
document.getElementById('root').innerHTML = `
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
${data.title ? `<h3 class="text-lg font-semibold text-gray-900 mb-6">${data.title}</h3>` : ''}
<div class="flex items-end justify-between gap-2 h-[200px] mb-4">
${data.bars.map((bar, i) => {
const height = max > 0 ? (bar.value / max) * 100 : 0;
return `
<div class="flex-1 flex flex-col items-center justify-end h-full">
<span class="text-xs font-semibold text-gray-700 mb-1">${bar.value}</span>
<div class="w-full rounded-t-lg ${colors[i % colors.length]}" style="height: ${height}%"></div>
</div>
`;
}).join('')}
</div>
<div class="flex justify-between border-t border-gray-100 pt-3">
${data.bars.map(bar => `
<div class="flex-1 text-center">
<span class="text-xs text-gray-500">${bar.label}</span>
</div>
`).join('')}
</div>
</div>
`;
}
Comparison Cards
function render(data) {
if (rendered || !data?.scenarios) return;
rendered = true;
const fmt = n => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
document.getElementById('root').innerHTML = `
<div class="grid grid-cols-1 md:grid-cols-${Math.min(data.scenarios.length, 3)} gap-4">
${data.scenarios.map(s => `
<div class="relative bg-white rounded-xl shadow-sm border-2 ${s.recommended ? 'border-blue-500 ring-2 ring-blue-100' : 'border-gray-200'}">
${s.recommended ? `<div class="bg-blue-500 text-white text-xs font-semibold px-3 py-1 text-center">RECOMMENDED</div>` : ''}
<div class="p-6">
<h3 class="text-lg font-bold text-gray-900 mb-2">${s.label}</h3>
<div class="text-3xl font-bold text-gray-900 mb-4">${fmt(s.monthlyPayment)}<span class="text-sm font-normal text-gray-500">/mo</span></div>
<ul class="space-y-2 text-sm">
${Object.entries(s).filter(([k]) => !['label','recommended','monthlyPayment'].includes(k)).map(([k,v]) => `
<li class="flex justify-between"><span class="text-gray-500">${k}</span><span class="font-medium">${typeof v === 'number' ? fmt(v) : v}</span></li>
`).join('')}
</ul>
</div>
</div>
`).join('')}
</div>
`;
}
Helper Functions
function formatCurrency(n) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
}
function formatCompact(n) {
if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
if (n >= 1000) return (n/1000).toFixed(1) + 'K';
return n.toLocaleString();
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
Tailwind Best Practices
-
Always include CDN -
<script src="https://cdn.tailwindcss.com"></script> -
Use gradient backgrounds -
bg-gradient-to-br from-gray-50 to-gray-100 -
Add shadows and borders -
shadow-sm border border-gray-200 rounded-xl -
Include hover states -
hover:shadow-md hover:border-gray-300 transition-all -
Use consistent spacing -
p-4 p-5 p-6andgap-4 - Include loading spinner - Animated SVG during load
-
Handle hydration - Check
renderedflag
Checklist
- Widget config added to
widgetsarray - Widget HTML with Tailwind added to
generateWidgetHtml() - Tool created/updated with
widgetId - Mock data provided for preview
- Preview tested at
/preview/{widget-id} - XSS prevention (escape user content)
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-03-11
- Index updated
- 2026-03-11
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!