Home / Skills / Web & UI Design / Web Artifacts Builder
Web & UI Design

Web Artifacts Builder

Anthropic, PBCApache-2.0Skill language: English
License verifiedIncludes scriptsNeeds external service
DemoReal run · claude-sonnet-5-5 · October 2026
Create a polished single-page React dashboard artifact for a global product team to review weekly customer-feedback themes. It needs a summary header, filterable issue categories, a detail panel, and a dark/light theme toggle. Use the web-artifacts-builder skill and explain the project initialization and bundling commands you would use.
Skill: web-artifacts-builder
Skill: dataviz
Read: /private/tmp/claude-501/bundled-skills/2.1.289/8afa2dbf89ca47df4ce97a4b010a0951/dataviz/references/palette.md
Read: scripts/init-artifact.sh

I can't run anything in this session. I only have the file-read and skill tools, with no shell and no file-write tool. So nothing below has been executed, bundled or checked in a browser. This is the exact workflow plus the source to drop in.

1. Initialize the project

The skill's init-artifact.sh (I read it) needs Node 18+. It installs pnpm if it's missing. It then runs pnpm create vite with the react-ts template and pins Tailwind 3.4.1. After that it adds the @/ alias, installs the Radix and shadcn dependencies, and unpacks the 40+ shadcn components from shadcn-components.tar.gz.

cd /private/var/folders/ny/1wbtvz6s0hd918ll0msz_5680000gn/T/skill-trial-0cypru7o
bash .claude/skills/web-artifacts-builder/scripts/init-artifact.sh feedback-dashboard
cd feedback-dashboard

2. Develop

Replace src/App.tsx with the code below. Then run pnpm dev to preview. It uses only React and Tailwind, so it has no extra dependencies. The colors come from the dataviz reference palette: categorical slots in fixed order, a light and a dark set, and status colors that always appear with an icon and a label.

import { useEffect, useMemo, useState } from "react";

type Sev = "Critical" | "High" | "Medium" | "Low";
type Issue = {
  id: string; title: string; cat: string; mentions: number; prev: number; sev: Sev;
  summary: string; trend: number[]; regions: [string, number][]; quotes: string[];
};

const CATS = ["Performance", "Onboarding", "Billing", "Integrations", "Mobile"] as const;
const WEEKS = ["W36", "W37", "W38", "W39", "W40", "W41", "W42", "W43"];

const ISSUES: Issue[] = [
  { id: "FB-101", title: "Dashboard loads slowly on large workspaces", cat: "Performance", mentions: 412, prev: 301, sev: "Critical",
    summary: "Workspaces with 10k+ records take 8–12s to render the home dashboard. Spike followed the W41 release.",
    trend: [180, 190, 210, 230, 260, 301, 380, 412], regions: [["NA", 160], ["EMEA", 148], ["APAC", 82], ["LATAM", 22]],
    quotes: ["“Monday mornings I just wait for the spinner.” — EMEA, Enterprise", "“Used to be instant before the last update.” — NA, Team"] },
  { id: "FB-102", title: "Setup wizard fails to import CSV with UTF-8 BOM", cat: "Onboarding", mentions: 187, prev: 164, sev: "High",
    summary: "Import silently stops at step 3 for files exported from Excel. Mostly new accounts in their first week.",
    trend: [120, 125, 140, 150, 152, 164, 175, 187], regions: [["APAC", 74], ["EMEA", 61], ["NA", 40], ["LATAM", 12]],
    quotes: ["“No error, it just stops.” — APAC, Trial", "“Almost churned on day two.” — EMEA, Trial"] },
  { id: "FB-103", title: "Invoices show wrong currency after plan change", cat: "Billing", mentions: 143, prev: 158, sev: "High",
    summary: "Mid-cycle plan changes render invoices in USD regardless of account currency. Fix shipped W42; tail remains.",
    trend: [60, 72, 95, 120, 150, 158, 150, 143], regions: [["EMEA", 70], ["LATAM", 41], ["APAC", 24], ["NA", 8]],
    quotes: ["“Finance flagged it twice this month.” — EMEA, Enterprise"] },
  { id: "FB-104", title: "Slack integration drops threaded replies", cat: "Integrations", mentions: 96, prev: 71, sev: "Medium",
    summary: "Replies in threads are not synced back to the linked item. Reported by teams relying on Slack as the main inbox.",
    trend: [40, 44, 50, 52, 58, 71, 85, 96], regions: [["NA", 44], ["EMEA", 33], ["APAC", 15], ["LATAM", 4]],
    quotes: ["“We lose context every time someone replies in thread.” — NA, Team"] },
  { id: "FB-105", title: "iOS app logs users out after backgrounding", cat: "Mobile", mentions: 131, prev: 118, sev: "High",
    summary: "Session token is not refreshed when the app resumes after 30+ minutes. Affects iOS 18 only.",
    trend: [90, 95, 99, 104, 110, 118, 126, 131], regions: [["NA", 52], ["APAC", 39], ["EMEA", 33], ["LATAM", 7]],
    quotes: ["“Have to log in every single time I open it.” — APAC, Team"] },
  { id: "FB-106", title: "Search ignores accented characters", cat: "Performance", mentions: 58, prev: 61, sev: "Low",
    summary: "Searching “cafe” does not match “café”. Primarily non-English workspaces.",
    trend: [50, 55, 60, 62, 60, 61, 59, 58], regions: [["EMEA", 31], ["LATAM", 19], ["APAC", 6], ["NA", 2]],
    quotes: ["“Our whole catalog is in French.” — EMEA, Team"] },
  { id: "FB-107", title: "Unclear pricing page for seat add-ons", cat: "Billing", mentions: 74, prev: 66, sev: "Medium",
    summary: "Prospects can’t tell whether add-on seats are billed monthly or annually.",
    trend: [30, 35, 41, 52, 60, 66, 70, 74], regions: [["NA", 30], ["EMEA", 26], ["APAC", 14], ["LATAM", 4]],
    quotes: ["“Is it per month or per year? Couldn’t tell.” — NA, Trial"] },
  { id: "FB-108", title: "Android push notifications arrive late", cat: "Mobile", mentions: 49, prev: 52, sev: "Medium",
    summary: "Delays of 5–15 minutes on devices with aggressive battery optimisation.",
    trend: [58, 60, 55, 54, 53, 52, 50, 49], regions: [["APAC", 22], ["LATAM", 14], ["EMEA", 9], ["NA", 4]],
    quotes: ["“By the time I get it the meeting is over.” — LATAM, Team"] },
];

// Fixed categorical slots (light / dark) — color follows the category, never its rank.
const SLOT: Record<string, number> = Object.fromEntries(CATS.map((c, i) => [c, i + 1]));
const SEV_ICON: Record<Sev, string> = { Critical: "◆", High: "▲", Medium: "●", Low: "○" };
const SEV_VAR: Record<Sev, string> = { Critical: "--crit", High: "--serious", Medium: "--warn", Low: "--good" };

const CSS = `
.viz{--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink2:#52514e;--muted:#898781;--grid:#e1e0d9;--rule:rgba(11,11,11,.1);--sel:#eeede8;
--s1:#2a78d6;--s2:#eb6834;--s3:#1baf7a;--s4:#eda100;--s5:#e87ba4;--good:#0ca30c;--warn:#fab219;--serious:#ec835a;--crit:#d03b3b;--up:#006300;color-scheme:light}
.dark .viz{--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink2:#c3c2b7;--grid:#2c2c2a;--rule:rgba(255,255,255,.1);--sel:#262624;
--s1:#3987e5;--s2:#d95926;--s3:#199e70;--s4:#c98500;--s5:#d55181;--up:#0ca30c}
.viz{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--page);color:var(--ink)}
`;

const delta = (i: Issue) => Math.round(((i.mentions - i.prev) / i.prev) * 100);

function Spark({ data, color }: { data: number[]; color: string }) {
  const [hover, setHover] = useState<number | null>(null);
  const W = 420, H = 110, P = 8, max = Math.max(...data) * 1.1;
  const x = (i: number) => P + (i * (W - 2 * P)) / (data.length - 1);
  const y = (v: number) => H - P - (v / max) * (H - 2 * P);
  const path = data.map((v, i) => `${i ? "L" : "M"}${x(i)},${y(v)}`).join(" ");
  const k = hover ?? data.length - 1;
  return (
    <div>
      <div className="mb-1 flex items-baseline justify-between text-xs" style={{ color: "var(--ink2)" }}>
        <span>{WEEKS[k]}</span><span className="text-sm font-semibold" style={{ color: "var(--ink)" }}>{data[k]} mentions</span>
      </div>
      <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label="Weekly mentions trend"
        onMouseLeave={() => setHover(null)}
        onMouseMove={(e) => {
          const r = e.currentTarget.getBoundingClientRect();
          const rel = ((e.clientX - r.left) / r.width) * W;
          setHover(Math.max(0, Math.min(data.length - 1, Math.round(((rel - P) / (W - 2 * P)) * (data.length - 1)))));
        }}>
        {[0, 0.5, 1].map((t) => <line key={t} x1={P} x2={W - P} y1={H - P - t * (H - 2 * P) / 1.1} y2={H - P - t * (H - 2 * P) / 1.1} stroke="var(--grid)" />)}
        <path d={path} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
        <line x1={x(k)} x2={x(k)} y1={P} y2={H - P} stroke="var(--muted)" strokeDasharray="2 3" />
        <circle cx={x(k)} cy={y(data[k])} r={4.5} fill={color} stroke="var(--surface)" strokeWidth={2} />
      </svg>
      <div className="flex justify-between text-[11px]" style={{ color: "var(--muted)" }}><span>{WEEKS[0]}</span><span>{WEEKS[WEEKS.length - 1]}</span></div>
    </div>
  );
}

export default function App() {
  const [dark, setDark] = useState(() => window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false);
  const [active, setActive] = useState<string[]>([]);
  const [sort, setSort] = useState<"mentions" | "growth">("mentions");
  const [selId, setSelId] = useState(ISSUES[0].id);

  useEffect(() => { document.documentElement.classList.toggle("dark", dark); }, [dark]);

  const rows = useMemo(() => {
    const r = ISSUES.filter((i) => !active.length || active.includes(i.cat));
    return [...r].sort((a, b) => (sort === "mentions" ? b.mentions - a.mentions : delta(b) - delta(a)));
  }, [active, sort]);
  const sel = rows.find((i) => i.id === selId) ?? rows[0];

  const total = ISSUES.reduce((s, i) => s + i.mentions, 0);
  const prev = ISSUES.reduce((s, i) => s + i.prev, 0);
  const crit = ISSUES.filter((i) => i.sev === "Critical" || i.sev === "High").reduce((s, i) => s + i.mentions, 0);
  const fastest = [...ISSUES].sort((a, b) => delta(b) - delta(a))[0];
  const col = (c: string) => `var(--s${SLOT[c]})`;
  const toggle = (c: string) => setActive((a) => (a.includes(c) ? a.filter((x) => x !== c) : [...a, c]));

  return (
    <div className="viz min-h-screen">
      <style>{CSS}</style>
      <div className="mx-auto max-w-[1240px] px-6 py-8">
        {/* Summary header */}
        <header className="flex flex-wrap items-start justify-between gap-6 border-b pb-6" style={{ borderColor: "var(--rule)" }}>
          <div>
            <p className="text-xs uppercase tracking-widest" style={{ color: "var(--muted)" }}>Customer feedback · Week 43 · Oct 20–26 · All regions</p>
            <h1 className="mt-1 text-3xl font-semibold tracking-tight">Weekly feedback themes</h1>
          </div>
          <button onClick={() => setDark(!dark)} aria-pressed={dark} aria-label="Toggle dark mode"
            className="rounded-md border px-3 py-1.5 text-sm" style={{ borderColor: "var(--rule)", background: "var(--surface)", color: "var(--ink2)" }}>
            {dark ? "☀ Light" : "☾ Dark"}
          </button>
        </header>

        <section className="mt-6 grid grid-cols-2 gap-px overflow-hidden rounded-md border lg:grid-cols-4" style={{ borderColor: "var(--rule)", background: "var(--rule)" }}>
          {[
            ["Total mentions", total.toLocaleString(), `${total > prev ? "▲" : "▼"} ${Math.abs(Math.round(((total - prev) / prev) * 100))}% vs last week`],
            ["High-severity share", `${Math.round((crit / total) * 100)}%`, `${crit.toLocaleString()} mentions Critical/High`],
            ["Fastest growing", `+${delta(fastest)}%`, fastest.title],
            ["Open themes", String(ISSUES.length), `${CATS.length} categories`],
          ].map(([l, v, s]) => (
            <div key={l} className="p-4" style={{ background: "var(--surface)" }}>
              <div className="text-xs" style={{ color: "var(--ink2)" }}>{l}</div>
              <div className="mt-1 text-3xl font-semibold">{v}</div>
              <div className="mt-1 truncate text-xs" style={{ color: "var(--muted)" }}>{s}</div>
            </div>
          ))}
        </section>

        {/* Filters */}
        <div className="mt-6 flex flex-wrap items-center gap-2">
          <span className="mr-1 text-xs" style={{ color: "var(--muted)" }}>Category</span>
          {CATS.map((c) => {
            const on = active.includes(c);
            const n = ISSUES.filter((i) => i.cat === c).reduce((s, i) => s + i.mentions, 0);
            return (
              <button key={c} onClick={() => toggle(c)} aria-pressed={on}
                className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm"
                style={{ borderColor: on ? "var(--ink)" : "var(--rule)", background: on ? "var(--sel)" : "var(--surface)", color: "var(--ink)" }}>
                <span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: col(c) }} />
                {c}<span style={{ color: "var(--muted)" }}>{n}</span>
              </button>
            );
          })}
          {active.length > 0 && <button onClick={() => setActive([])} className="text-sm underline" style={{ color: "var(--ink2)" }}>Clear</button>}
          <div className="ml-auto flex items-center gap-2 text-xs" style={{ color: "var(--muted)" }}>
            Sort
            {(["mentions", "growth"] as const).map((s) => (
              <button key={s} onClick={() => setSort(s)} className="rounded px-2 py-1"
                style={{ background: sort === s ? "var(--sel)" : "transparent", color: sort === s ? "var(--ink)" : "var(--ink2)" }}>{s}</button>
            ))}
          </div>
        </div>

        {/* List + detail */}
        <div className="mt-4 grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
          <ul className="overflow-hidden rounded-md border" style={{ borderColor: "var(--rule)", background: "var(--surface)" }}>
            {rows.length === 0 && <li className="p-6 text-sm" style={{ color: "var(--ink2)" }}>No themes match this filter.</li>}
            {rows.map((i) => {
              const d = delta(i), on = sel?.id === i.id, max = rows[0] && Math.max(...rows.map((r) => r.mentions));
              return (
                <li key={i.id} style={{ borderTop: "1px solid var(--rule)" }} className="first:border-t-0">
                  <button onClick={() => setSelId(i.id)} aria-current={on} className="block w-full px-4 py-3 text-left"
                    style={{ background: on ? "var(--sel)" : "transparent", boxShadow: on ? `inset 3px 0 0 ${col(i.cat)}` : "none" }}>
                    <div className="flex items-start justify-between gap-4">
                      <div className="min-w-0">
                        <div className="text-sm font-medium">{i.title}</div>
                        <div className="mt-1 flex items-center gap-3 text-xs" style={{ color: "var(--ink2)" }}>
                          <span className="flex items-center gap-1.5"><span className="inline-block h-2 w-2 rounded-sm" style={{ background: col(i.cat) }} />{i.cat}</span>
                          <span style={{ color: `var(${SEV_VAR[i.sev]})` }}>{SEV_ICON[i.sev]}</span><span>{i.sev}</span>
                        </div>
                      </div>
                      <div className="shrink-0 text-right">
                        <div className="text-sm font-semibold">{i.mentions}</div>
                        <div className="text-xs" style={{ color: d > 0 ? "var(--ink2)" : "var(--up)" }}>{d > 0 ? "▲" : "▼"} {Math.abs(d)}%</div>
                      </div>
                    </div>
                    <div className="mt-2 h-1" style={{ background: "var(--grid)" }}>
                      <div className="h-full rounded-r-sm" style={{ width: `${(i.mentions / max) * 100}%`, background: col(i.cat) }} />
                    </div>
                  </button>
                </li>
              );
            })}
          </ul>

          {/* Detail panel */}
          {sel && (
            <aside className="self-start rounded-md border p-5 lg:sticky lg:top-6" style={{ borderColor: "var(--rule)", background: "var(--surface)" }}>
              <div className="flex items-center gap-2 text-xs" style={{ color: "var(--ink2)" }}>
                <span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: col(sel.cat) }} />{sel.cat}<span>·</span><span>{sel.id}</span>
                <span className="ml-auto" style={{ color: `var(${SEV_VAR[sel.sev]})` }}>{SEV_ICON[sel.sev]}</span><span>{sel.sev}</span>
              </div>
              <h2 className="mt-2 text-xl font-semibold leading-snug">{sel.title}</h2>
              <p className="mt-2 text-sm leading-relaxed" style={{ color: "var(--ink2)" }}>{sel.summary}</p>

              <h3 className="mb-2 mt-5 text-xs uppercase tracking-widest" style={{ color: "var(--muted)" }}>8-week trend</h3>
              <Spark data={sel.trend} color={col(sel.cat)} />

              <h3 className="mb-2 mt-5 text-xs uppercase tracking-widest" style={{ color: "var(--muted)" }}>By region</h3>
              <div className="space-y-2">
                {sel.regions.map(([r, v]) => (
                  <div key={r} className="grid grid-cols-[52px_1fr_36px] items-center gap-2 text-xs" title={`${r}: ${v} mentions`}>
                    <span style={{ color: "var(--ink2)" }}>{r}</span>
                    <div className="h-2" style={{ background: "var(--grid)" }}>
                      <div className="h-full rounded-r-sm" style={{ width: `${(v / sel.regions[0][1]) * 100}%`, background: col(sel.cat) }} />
                    </div>
                    <span className="text-right">{v}</span>
                  </div>
                ))}
              </div>

              <h3 className="mb-2 mt-5 text-xs uppercase tracking-widest" style={{ color: "var(--muted)" }}>Representative quotes</h3>
              <ul className="space-y-2">
                {sel.quotes.map((q) => <li key={q} className="border-l-2 pl-3 text-sm italic" style={{ borderColor: "var(--rule)", color: "var(--ink2)" }}>{q}</li>)}
              </ul>
            </aside>
          )}
        </div>
      </div>
    </div>
  );
}

The sample data is placeholder. Swap ISSUES for your real feed. Colors follow each category and not its rank, so filtering never repaints the remaining categories.

3. Bundle to a single HTML file

bash ../.claude/skills/web-artifacts-builder/scripts/bundle-artifact.sh

Run it from inside feedback-dashboard, where index.html sits at the root. Per the skill, the script does four things:

  • It installs Parcel, @parcel/config-default, parcel-resolver-tspaths and html-inline.
  • It writes a .parcelrc that supports the @/ alias.
  • It builds with Parcel and no source maps.
  • It inlines all the JS and CSS into bundle.html, which you can share as the artifact.

What I'd still check

  • Palette validation: I haven't run validate_palette.js. The five categorical slots are the first five of the reference palette, which is documented as validated for adjacent pairs. The yellow and magenta slots are below 3:1 contrast on the light surface. They're always paired with a visible text label, as the relief rule requires.
  • Build and visual check: I haven't built the code or looked at it in a browser. I'd do both after bundling.

If you give me shell and write access, I'll run these steps, fix any build errors and screenshot both themes.

Real run recorded with claude-code / claude-sonnet-5-5. Output is shown verbatim, unmodified.

What it does

Guides agents through creating multi-component web artifacts for complex interfaces that need state, routing, or reusable UI components.

How it works

  1. Initializes a React, TypeScript, Vite, Tailwind CSS, and shadcn/ui project.
  2. Develops the artifact with the generated component set.
  3. Bundles JavaScript, CSS, and assets into one shareable HTML file.

Good for

Interactive dashboards, multi-panel tools, data-heavy experiences, and other complex browser artifacts.

Notes & risks

Contains two shell scripts and a bundled set of shadcn/ui source components. Initialization can install pnpm globally, download npm packages, create and modify a project directory, and extract components. Bundling adds dependencies, writes a Parcel configuration, removes prior relative build output, and writes bundle.html. It needs network access for packages but no credentials; review commands and target paths before running.