Skip to content

The component contract

A component is one file whose default export is a defineTaggorComponent call, from the @taggor/component SDK. The call declares everything Taggor needs to know about the component, its manifest, and a render function that returns a Preact view. The build reads the manifest from the source, checks it against the contract, and the runtime mounts the component in a Shadow DOM on your site, with an error boundary around it.

The numbers and tables on this page are generated from the contract package, and the code is copied from templates that have their own tests, so the page cannot drift from what the build checks.

src/index.tsx (excerpt of the faq-accordion template)
import { defineTaggorComponent, prop, useLocale, useTrack } from "@taggor/component";
export default defineTaggorComponent({
name: "faq-accordion",
tag: "faq-accordion",
title: "FAQ accordion",
description: "Lists questions whose answers open on click or with the keyboard.",
props: {
headline: prop.string({ maxLength: 120, description: "Replaces the default heading." }),
items: prop.array({
description: "The questions and answers, in the site's language.",
items: {
type: "object",
fields: {
question: { type: "string", minLength: 1, maxLength: 200 },
answer: { type: "string", minLength: 1, maxLength: 2000 },
},
required: ["question", "answer"],
},
minItems: 1,
maxItems: 30,
default: [
{
question: "Como funciona a entrega?",
answer: "Enviamos para todo o Brasil em até 5 dias úteis.",
},
{
question: "Posso trocar um produto?",
answer: "Sim, em até 30 dias após o recebimento, sem custo.",
},
{
question: "Quais formas de pagamento vocês aceitam?",
answer: "Pix, boleto e cartão de crédito em até 12 vezes.",
},
],
}),
multiple: prop.boolean({ description: "Lets several answers stay open at once." }),
},
events: {
toggle: {
description: "The visitor opened or closed an answer.",
detail: {
type: "object",
fields: {
index: { type: "number", integer: true, min: 0 },
open: { type: "boolean" },
},
required: ["index", "open"],
},
},
},
data: {
track: { "faq-open": { props: { index: { type: "number", integer: true, min: 0, max: 29 } } } },
},
parts: {
question: { description: "Each question button." },
answer: { description: "Each answer panel." },
},
cssVars: {
"--faq-accent": { type: "color", default: "#0f766e", description: "Open question color." },
"--faq-border": { type: "color", default: "#d1d5db", description: "Divider color." },
},
// locales and styles: see the template
// render(props, context): next block
});
  • name and tag: the component’s id and the custom element’s tag. A tag is lowercase, has a hyphen, and cannot start with taggor-.
  • title and description: shown in the portal and to agents.
  • props, events, slots, parts, cssVars, data: the sections below.
render of the same component
render: (props, context) => {
const { t } = useLocale(context);
const track = useTrack(context);
const { part, emit } = context;
const [open, setOpen] = useState<ReadonlySet<number>>(new Set());
const toggle = (index: number) => {
const opening = !open.has(index);
const next = new Set(props.multiple ? open : []);
if (opening) next.add(index);
else next.delete(index);
setOpen(next);
emit("toggle", { index, open: opening });
if (opening) track("faq-open", { index });
};
const allOpen = open.size === props.items.length;
return (
<section class="card">
<div class="top">
<h2>{props.headline ?? t("headline")}</h2>
{props.multiple ? (
<button
type="button"
class="all"
onClick={() => {
setOpen(new Set(allOpen ? [] : props.items.map((_item, index) => index)));
}}
>
{allOpen ? t("closeAll") : t("openAll")}
</button>
) : null}
</div>
{props.items.map((item, index) => {
const expanded = open.has(index);
return (
<div class="item" key={index}>
<h3>
<button
type="button"
id={`q${String(index)}`}
part={part("question")}
aria-expanded={expanded ? "true" : "false"}
aria-controls={`a${String(index)}`}
onClick={() => {
toggle(index);
}}
>
{item.question}
</button>
</h3>
<div
id={`a${String(index)}`}
role="region"
aria-labelledby={`q${String(index)}`}
part={part("answer")}
hidden={!expanded}
>
{item.answer}
</div>
</div>
);
})}
</section>
);
},
});

render receives the typed props and a context with emit (events), part (theming) and what the hooks need (useLocale, useTrack, useTaggorData, useSlot).

A prop is a typed, bounded value the host page sets as an attribute (max-value="10") or as a property on the element. The attribute name is the kebab-case form of the prop name. Every prop has a type, and a value that does not fit it falls back to the default.

Type SDK builder Options
string prop.string() minLength, maxLength, format (email, url, tel, date, date-time), default
number prop.number() min, max, integer, default, step
boolean prop.boolean() default
enum prop.enum() values, default
color prop.color() default
url prop.url() protocols, default
date prop.date() min, max, default
array prop.array() items, minItems, maxItems, default
object prop.object() fields, required, default

An event is declared with a description and the shape of its detail. The component calls emit("toggle", { index, open }), and the page receives a DOM CustomEvent named <tag>:<event>, here faq-accordion:toggle, with that detail:

document.querySelector("faq-accordion").addEventListener("faq-accordion:toggle", (event) => {
console.log(event.detail.index, event.detail.open);
});

A slot is a place where the page can put its own HTML inside the component. Declare it in slots and render a native <slot name="…"> where it should appear. useSlot("title").hasContent tells the component whether the page filled it.

a component with one slot (imports left out)
export default defineTaggorComponent({
name: "fallback-card",
tag: "fallback-card",
title: "Fallback card",
description: "A call to action whose title the page can replace; plain HTML stands in for it.",
props: { headline: prop.string({ maxLength: 60, default: "Simule seu financiamento" }) },
slots: { title: { description: "Replaces the default headline." } },
styles: [
":host { display: block; min-height: 120px; padding: 8px; box-sizing: border-box }",
"h3 { margin: 0 0 8px }",
],
render: ({ headline }) => {
const title = useSlot("title");
return h(
"section",
// Exposed for the browser test: what `useSlot` reported on the last render.
{ "data-title-slot": title.hasContent ? "filled" : "empty" },
h("h3", null, title.hasContent ? h("slot", { name: "title" }) : headline),
h("button", { type: "button" }, "Simular"),
);
},
});

The HTML the page writes inside the tag that has no slot attribute is the fallback: it is what a visitor sees before the component loads, or if it never does. How to write it is in Install the snippet.

A font is a .woff2 file in the component’s folder, imported and registered with useFont; fontFamily gives the name to write in the CSS. Every component that shows the font calls useFont, and the file is one cached object for all of them. A @font-face rule in the component’s CSS does not apply inside a Shadow DOM.

Every component starts with :host { display: block; contain: content }. The containment keeps the component from painting over the page, and it also cuts what an element paints outside the component’s own box: a box-shadow, a drop-shadow(), a focus outline pushed outward. Leave room inside the component (padding on an inner wrapper), or add :host { contain: none } to its styles when the paint must leave the box.

parts names the elements a page may style with ::part(name), and cssVars the CSS variables it may set (style="--faq-accent: #7c3aed"), each with a type and a default. Nothing else of the component’s styles is reachable from the page, and the page’s CSS cannot reach into the component.

A component that collects or records something declares it in data, and reads and writes it through the useTaggorData and useTrack hooks. The data belongs to the customer; the visitor is never tracked across sites, and no metric carries personal data.

the data declaration of the contact-form template
data: {
collections: {
contacts: {
fields: {
name: { type: "string", minLength: 1, maxLength: 120 },
email: { type: "string", format: "email" },
message: { type: "string", maxLength: 2000 },
},
required: ["name", "email"],
},
},
track: { "contact-submit": { props: { ok: { type: "boolean" } } } },
personal: true,
privacyPolicyUrl: "privacyPolicy",
},
  • collections are the tables the component can submit to, with typed fields. The component’s data can be read back with taggor data export. The whole flow, with a complete example, is in Forms and data.
  • track names the interactions to measure, with a few small typed props; never free text.
  • personal: true makes every submission need the visitor’s consent, and privacyPolicyUrl names the prop that holds the link to the site’s privacy policy.

The build refuses a manifest above these limits.

What Limit
Props 50
Events 20
Slots 10
Parts 30
CSS variables 30
Data collections 10
Fields per collection 50
Tracked interactions 30
Values of one enum 50
Manifest size (characters of JSON) 65,536

On publish the builder checks the manifest, types, size, CSS and security rules. A failure prints a code with a page under build diagnostics: what it means and how to fix it. The JSON Schema of the manifest is public at /schema/component-manifest/v1.json, so an editor can validate a manifest and an agent can read the contract.