Component Builder Guide
SpiderPublish components are reusable UI blocks that render inside Declarative Shadow DOM — CSS and JS are fully isolated per component. No CSS leaks, no naming collisions, no matter which agent or developer built them.
Components support 4 interactivity tiers. Each tier builds on the previous one:
| Tier | Name | What You Add |
|---|---|---|
| 1 | Static | html_template + css |
| 2 | Interactive | + js (vanilla, scoped to shadow root) |
| 3 | Rich | + dependencies (CDN libraries like GSAP, Chart.js) |
| 4 | App | + framework + source_code (React/Vue/Svelte) |
For a detailed comparison of tiers, see the Tiers Reference. For a machine-readable reference optimized for AI agents, see the Agent Reference.
Quick Start: Static Component (Tier 1)
Create a hero section component with a headline and CTA button. No JavaScript — pure HTML + CSS rendered in Shadow DOM.
Step 1: Create the Component
- cURL
- Python
- JavaScript
curl -X POST "https://spideriq.ai/api/v1/dashboard/content/components" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "hero-gradient",
"name": "Gradient Hero",
"category": "hero",
"version": "1.0.0",
"html_template": "<section class=\"hero\">\n <h1>{{ props.headline }}</h1>\n <p>{{ props.subheadline }}</p>\n <a href=\"{{ props.cta_url }}\" class=\"btn\">{{ props.cta_text }}</a>\n</section>",
"css": ":host { display: block; }\n.hero { background: linear-gradient(135deg, var(--primary), #000); padding: 5rem 2rem; text-align: center; color: white; }\nh1 { font-size: 3rem; margin: 0 0 1rem; }\np { font-size: 1.25rem; opacity: 0.85; margin: 0 0 2rem; }\n.btn { display: inline-block; padding: 0.75rem 2rem; background: white; color: var(--primary); border-radius: 0.5rem; text-decoration: none; font-weight: 600; }",
"props_schema": {
"type": "object",
"properties": {
"headline": { "type": "string", "title": "Headline" },
"subheadline": { "type": "string", "title": "Subheadline" },
"cta_text": { "type": "string", "title": "Button Text" },
"cta_url": { "type": "string", "title": "Button URL" }
},
"required": ["headline"]
},
"default_props": {
"subheadline": "Build something great.",
"cta_text": "Get Started",
"cta_url": "/signup"
}
}'
import httpx
resp = httpx.post(
"https://spideriq.ai/api/v1/dashboard/content/components",
headers={"Authorization": f"Bearer {CLIENT_TOKEN}"},
json={
"slug": "hero-gradient",
"name": "Gradient Hero",
"category": "hero",
"version": "1.0.0",
"html_template": '<section class="hero">\n <h1>{{ props.headline }}</h1>\n <p>{{ props.subheadline }}</p>\n <a href="{{ props.cta_url }}" class="btn">{{ props.cta_text }}</a>\n</section>',
"css": ":host { display: block; }\n.hero { background: linear-gradient(135deg, var(--primary), #000); padding: 5rem 2rem; text-align: center; color: white; }\nh1 { font-size: 3rem; margin: 0 0 1rem; }\np { font-size: 1.25rem; opacity: 0.85; margin: 0 0 2rem; }\n.btn { display: inline-block; padding: 0.75rem 2rem; background: white; color: var(--primary); border-radius: 0.5rem; text-decoration: none; font-weight: 600; }",
"props_schema": {
"type": "object",
"properties": {
"headline": {"type": "string", "title": "Headline"},
"subheadline": {"type": "string", "title": "Subheadline"},
"cta_text": {"type": "string", "title": "Button Text"},
"cta_url": {"type": "string", "title": "Button URL"},
},
"required": ["headline"],
},
"default_props": {
"subheadline": "Build something great.",
"cta_text": "Get Started",
"cta_url": "/signup",
},
},
)
component = resp.json()
print(component["id"])
const resp = await fetch("https://spideriq.ai/api/v1/dashboard/content/components", {
method: "POST",
headers: {
"Authorization": `Bearer ${CLIENT_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
slug: "hero-gradient",
name: "Gradient Hero",
category: "hero",
version: "1.0.0",
html_template: `<section class="hero">
<h1>{{ props.headline }}</h1>
<p>{{ props.subheadline }}</p>
<a href="{{ props.cta_url }}" class="btn">{{ props.cta_text }}</a>
</section>`,
css: `:host { display: block; }
.hero { background: linear-gradient(135deg, var(--primary), #000); padding: 5rem 2rem; text-align: center; color: white; }
h1 { font-size: 3rem; margin: 0 0 1rem; }
p { font-size: 1.25rem; opacity: 0.85; margin: 0 0 2rem; }
.btn { display: inline-block; padding: 0.75rem 2rem; background: white; color: var(--primary); border-radius: 0.5rem; text-decoration: none; font-weight: 600; }`,
props_schema: {
type: "object",
properties: {
headline: { type: "string", title: "Headline" },
subheadline: { type: "string", title: "Subheadline" },
cta_text: { type: "string", title: "Button Text" },
cta_url: { type: "string", title: "Button URL" },
},
required: ["headline"],
},
default_props: {
subheadline: "Build something great.",
cta_text: "Get Started",
cta_url: "/signup",
},
}),
});
const component = await resp.json();
console.log(component.id);
Step 2: Publish
curl -X POST "https://spideriq.ai/api/v1/dashboard/content/components/{id}/publish" \
-H "Authorization: Bearer $CLIENT_TOKEN"
Step 3: Use in a Page Block
Add the component to any page's blocks array:
{
"id": "hero-1",
"type": "component",
"component_slug": "hero-gradient",
"props": {
"headline": "Ship Faster with AI",
"cta_text": "Start Free Trial",
"cta_url": "/trial"
}
}
Props you don't provide fall back to default_props — in this case subheadline defaults to "Build something great."
Your CSS can use var(--primary) — SpiderPublish automatically injects your site's primary_color as a CSS variable into every component's :host selector.
Adding Interactivity: Scoped JS (Tier 2)
Set the js field to add vanilla JavaScript that runs inside the shadow root. The JS receives two arguments:
root— the shadow root element (useroot.querySelector()to find elements)props— the merged props object (page block props + defaults)
Example: FAQ Accordion
curl -X POST "https://spideriq.ai/api/v1/dashboard/content/components" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "faq-accordion",
"name": "FAQ Accordion",
"category": "faq",
"html_template": "<div class=\"faq\">{% for item in props.items %}<div class=\"faq-item\"><button class=\"faq-q\">{{ item.question }}</button><div class=\"faq-a\" hidden>{{ item.answer }}</div></div>{% endfor %}</div>",
"css": ".faq-q { display: block; width: 100%; text-align: left; padding: 1rem; background: none; border: none; border-bottom: 1px solid #333; color: inherit; font-size: 1rem; cursor: pointer; font-family: var(--font-body, system-ui); }\n.faq-q:hover { background: rgba(255,255,255,0.05); }\n.faq-a { padding: 1rem; line-height: 1.6; }\n.faq-a[hidden] { display: none; }",
"js": "root.querySelectorAll(\".faq-q\").forEach(btn => { btn.addEventListener(\"click\", () => { const answer = btn.nextElementSibling; const isOpen = !answer.hidden; root.querySelectorAll(\".faq-a\").forEach(a => a.hidden = true); answer.hidden = isOpen; }); });",
"props_schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"question": { "type": "string" },
"answer": { "type": "string" }
}
}
}
},
"required": ["items"]
}
}'
- Always use
root.querySelector()— neverdocument.querySelector() - Your JS has no access to the parent DOM outside the shadow root
- No Tailwind — write plain CSS, it's scoped to this component only
- The
rootparameter IS theshadowRootelement
How It Works Under the Hood
- Your JS is stored as
<script type="spideriq/component-js">inside the shadow template (non-executable type) - A ~200-byte hydration script is injected once before
</body> - The hydration script finds all components with JS, reads the script content, and executes it via
new Function('root','props', code)(shadowRoot, mergedProps) - Each component gets its own execution scope — no collisions between components
Using CDN Libraries (Tier 3)
Need GSAP animations, Chart.js charts, or Swiper carousels? Add the dependencies field with keys from the CDN allowlist.
Discover Available Libraries
curl "https://spideriq.ai/api/v1/content/cdn-allowlist"
| Key | Library | Category | Description |
|---|---|---|---|
gsap | GSAP Core | animation | GreenSock animation library |
gsap/ScrollTrigger | GSAP ScrollTrigger | animation | Scroll-triggered animations |
gsap/Flip | GSAP Flip | animation | Layout transition animations |
animejs | anime.js | animation | Lightweight animation library |
alpinejs | Alpine.js | framework | Minimal reactive framework |
chartjs | Chart.js | visualization | Canvas charting library |
lottie | Lottie Web | animation | After Effects animation player |
swiper | Swiper | carousel | Touch slider/carousel |
countup | CountUp.js | animation | Animated number counter |
three | Three.js | 3d | WebGL 3D library |
Example: Animated Stats Bar with GSAP ScrollTrigger
curl -X POST "https://spideriq.ai/api/v1/dashboard/content/components" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "stats-animated",
"name": "Animated Stats Bar",
"category": "stats",
"dependencies": ["gsap", "gsap/ScrollTrigger"],
"html_template": "<section class=\"stats\">{% for stat in props.stats %}<div class=\"stat\"><span class=\"stat-value\" data-target=\"{{ stat.value }}\">0</span><span class=\"stat-label\">{{ stat.label }}</span></div>{% endfor %}</section>",
"css": ".stats { display: flex; justify-content: center; gap: 3rem; padding: 4rem 2rem; }\n.stat { text-align: center; }\n.stat-value { font-size: 3rem; font-weight: 700; color: var(--primary); display: block; }\n.stat-label { font-size: 0.875rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }",
"js": "gsap.registerPlugin(ScrollTrigger); root.querySelectorAll(\".stat-value\").forEach(el => { const target = parseInt(el.dataset.target, 10); gsap.fromTo(el, { textContent: 0 }, { textContent: target, duration: 2, ease: \"power2.out\", snap: { textContent: 1 }, scrollTrigger: { trigger: el, start: \"top 80%\" } }); });",
"props_schema": {
"type": "object",
"properties": {
"stats": {
"type": "array",
"items": {
"type": "object",
"properties": {
"value": { "type": "integer" },
"label": { "type": "string" }
}
}
}
},
"required": ["stats"]
}
}'
Libraries declared in dependencies are loaded via <script> tags in the page's <head>. If multiple components on the same page use the same library, it's loaded only once (deduplicated). Libraries use SRI hashes when available for security.
Framework Components (Tier 4)
For complex interactive UIs — dashboards, configurators, multi-step forms — use React, Vue, or Svelte. You submit source code; SpiderPublish builds it server-side with esbuild into a self-contained Web Component.
How It Works
- Create the component with
frameworkandsource_code - Publish — returns HTTP
202(build is async) - Poll
GET .../build-statusuntilbuild_status: "success" - The built bundle is uploaded to R2 CDN at
bundle_url - On the live page, it renders as
<spideriq-app-{slug}>with props passed viadata-props
Example: React Interactive Pricing Toggle
curl -X POST "https://spideriq.ai/api/v1/dashboard/content/components" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "pricing-toggle",
"name": "Pricing Toggle",
"category": "pricing",
"framework": "react",
"source_code": "import React, { useState } from \"react\";\n\nexport default function PricingToggle({ headline, plans }) {\n const [annual, setAnnual] = useState(false);\n return (\n <section style={{ padding: \"4rem 2rem\", textAlign: \"center\" }}>\n <h2 style={{ fontSize: \"2rem\", marginBottom: \"1rem\" }}>{headline}</h2>\n <button onClick={() => setAnnual(!annual)} style={{ padding: \"0.5rem 1.5rem\", borderRadius: \"2rem\", border: \"2px solid currentColor\", background: \"none\", color: \"inherit\", cursor: \"pointer\", marginBottom: \"2rem\" }}>\n {annual ? \"Annual (save 20%)\" : \"Monthly\"}\n </button>\n <div style={{ display: \"flex\", gap: \"2rem\", justifyContent: \"center\", flexWrap: \"wrap\" }}>\n {(plans || []).map((plan, i) => (\n <div key={i} style={{ border: \"1px solid #333\", borderRadius: \"1rem\", padding: \"2rem\", minWidth: \"250px\" }}>\n <h3>{plan.name}</h3>\n <p style={{ fontSize: \"2.5rem\", fontWeight: 700 }}>\n ${annual ? Math.round(plan.price * 0.8) : plan.price}<span style={{ fontSize: \"1rem\" }}>/mo</span>\n </p>\n <ul style={{ listStyle: \"none\", padding: 0 }}>\n {(plan.features || []).map((f, j) => <li key={j} style={{ padding: \"0.25rem 0\" }}>{f}</li>)}\n </ul>\n </div>\n ))}\n </div>\n </section>\n );\n}",
"props_schema": {
"type": "object",
"properties": {
"headline": { "type": "string" },
"plans": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"price": { "type": "number" },
"features": { "type": "array", "items": { "type": "string" } }
}
}
}
},
"required": ["headline", "plans"]
}
}'
Publish and Wait for Build
# Publish (returns 202 — build is async)
curl -X POST "https://spideriq.ai/api/v1/dashboard/content/components/{id}/publish" \
-H "Authorization: Bearer $CLIENT_TOKEN"
# Poll build status
curl "https://spideriq.ai/api/v1/dashboard/content/components/{id}/build-status" \
-H "Authorization: Bearer $CLIENT_TOKEN"
# Response: { "build_status": "building" }
# ... wait a few seconds ...
# Response: { "build_status": "success", "bundle_url": "https://media.cdn.spideriq.ai/components/..." }
Tier 4 publish returns HTTP 202, not 200. The build runs in the background. Always poll build-status before expecting the component to render on a live page. If the build fails, check build_error and use the rebuild endpoint after fixing the source.
Supported Frameworks
| Framework | source_code Format | Export |
|---|---|---|
react | JSX (React 18+) | export default function ComponentName(props) |
vue | Vue SFC (.vue format) | <template>, <script setup>, <style scoped> |
svelte | Svelte component | <script>, HTML, <style> |
Props Schema Design
The props_schema field uses JSON Schema to define what data your component accepts. Props are validated when a page block references the component.
Common Patterns
String with default:
{
"headline": { "type": "string", "title": "Headline", "default": "Welcome" }
}
Number with range:
{
"columns": { "type": "integer", "minimum": 1, "maximum": 6, "default": 3 }
}
Enum (select):
{
"style": { "type": "string", "enum": ["centered", "left", "split"], "default": "centered" }
}
Array of strings:
{
"features": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"maxItems": 12
}
}
Nested object (repeater):
{
"testimonials": {
"type": "array",
"items": {
"type": "object",
"properties": {
"quote": { "type": "string" },
"author": { "type": "string" },
"role": { "type": "string" },
"avatar_url": { "type": "string", "format": "uri" }
},
"required": ["quote", "author"]
}
}
}
Props Merge Behavior
When a page block references a component:
- Block's
propsare merged with the component'sdefault_props - Block props override defaults (block wins)
- The merged result is validated against
props_schema - In Liquid templates: access via
{{ props.headline }},{% for item in props.items %} - In JS (Tier 2): access via
props.headline,props.items - In frameworks (Tier 4): props passed as component props directly
Common Component Patterns
Carousel with Swiper (Tier 3)
{
"slug": "image-carousel",
"dependencies": ["swiper"],
"html_template": "<div class=\"swiper\"><div class=\"swiper-wrapper\">{% for slide in props.slides %}<div class=\"swiper-slide\"><img src=\"{{ slide.image }}\" alt=\"{{ slide.alt }}\" /></div>{% endfor %}</div><div class=\"swiper-pagination\"></div></div>",
"css": ".swiper { width: 100%; } .swiper-slide img { width: 100%; height: 400px; object-fit: cover; border-radius: 0.5rem; }",
"js": "new Swiper(root.querySelector('.swiper'), { loop: true, pagination: { el: root.querySelector('.swiper-pagination'), clickable: true }, autoplay: { delay: 4000 } });"
}
Animated Counter (Tier 3)
{
"slug": "counter-row",
"dependencies": ["countup"],
"html_template": "<div class=\"counters\">{% for c in props.counters %}<div class=\"counter\"><span class=\"num\" data-target=\"{{ c.value }}\">0</span><span class=\"label\">{{ c.label }}</span></div>{% endfor %}</div>",
"css": ".counters { display: flex; gap: 3rem; justify-content: center; padding: 3rem; }\n.num { font-size: 3rem; font-weight: 700; color: var(--primary); display: block; }\n.label { font-size: 0.875rem; opacity: 0.7; }",
"js": "root.querySelectorAll('.num').forEach(el => { const cu = new countUp.CountUp(el, parseInt(el.dataset.target), { duration: 2.5 }); const obs = new IntersectionObserver(entries => { if (entries[0].isIntersecting) { cu.start(); obs.disconnect(); } }); obs.observe(el); });"
}
Pricing Toggle (Tier 2)
{
"slug": "pricing-simple-toggle",
"html_template": "<div class=\"pricing\"><button class=\"toggle\">Monthly</button><div class=\"plans\">{% for plan in props.plans %}<div class=\"plan\" data-monthly=\"{{ plan.monthly }}\" data-annual=\"{{ plan.annual }}\"><h3>{{ plan.name }}</h3><span class=\"price\">${{ plan.monthly }}/mo</span></div>{% endfor %}</div></div>",
"css": ".toggle { padding: 0.5rem 1.5rem; border: 2px solid var(--primary); background: none; color: inherit; border-radius: 2rem; cursor: pointer; margin-bottom: 2rem; }\n.plans { display: flex; gap: 2rem; justify-content: center; }\n.plan { border: 1px solid #333; border-radius: 1rem; padding: 2rem; min-width: 220px; text-align: center; }\n.price { font-size: 2rem; font-weight: 700; }",
"js": "let annual = false; const btn = root.querySelector('.toggle'); btn.addEventListener('click', () => { annual = !annual; btn.textContent = annual ? 'Annual (save 20%)' : 'Monthly'; root.querySelectorAll('.plan').forEach(p => { const price = annual ? p.dataset.annual : p.dataset.monthly; p.querySelector('.price').textContent = '$' + price + '/mo'; }); });"
}
Versioning and Publishing
Status Flow
draft → published → archived
- draft — Component is editable, not visible on live pages
- published — Locked for rendering, visible on live pages
- archived — Hidden from listing, existing page references still render
Versioning
Components use semver (1.0.0, 1.1.0, 2.0.0). To create a new version, POST a new component with the same slug but a different version.
# List all versions of a component
curl "https://spideriq.ai/api/v1/dashboard/content/components/hero-gradient/versions" \
-H "Authorization: Bearer $CLIENT_TOKEN"
Pinning Versions in Page Blocks
{
"type": "component",
"component_slug": "hero-gradient",
"component_version": "1.0.0",
"props": { "headline": "Welcome" }
}
Omit component_version to always use the latest published version.
Using Components in Pages
The component Block Type
Add to any page's blocks array:
{
"id": "unique-block-id",
"type": "component",
"component_slug": "hero-gradient",
"component_version": "1.0.0",
"props": {
"headline": "Ship Faster with AI",
"subheadline": "The platform that turns your data into revenue."
}
}
Full Page Example
curl -X POST "https://spideriq.ai/api/v1/dashboard/content/pages" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Home",
"slug": "home",
"template": "landing",
"blocks": [
{
"id": "hero-1",
"type": "component",
"component_slug": "hero-gradient",
"props": { "headline": "Welcome to Acme" }
},
{
"id": "stats-1",
"type": "component",
"component_slug": "stats-animated",
"props": {
"stats": [
{ "value": 500, "label": "Clients" },
{ "value": 99, "label": "Uptime %" },
{ "value": 24, "label": "Countries" }
]
}
},
{
"id": "faq-1",
"type": "component",
"component_slug": "faq-accordion",
"props": {
"items": [
{ "question": "How does it work?", "answer": "Sign up, connect your data, deploy." },
{ "question": "What's the pricing?", "answer": "Free tier available, $29/mo for pro." }
]
}
}
]
}'
Each component renders in its own isolated Shadow DOM on the page — no CSS conflicts, even when mixing components from different sources.