Frameworks
Use the components from React, Vue, Angular, Svelte, Lit or plain HTML — typed events, generated template types, and the one piece of configuration each framework needs.
Every c2-* component is a standard custom element, so nothing here is a wrapper: the same element runs in every framework. What differs is how each one decides between an attribute and a property, and how it binds an event whose name has a hyphen in it. This guide is the short version of the three example apps — React, Vue and Angular — which are complete applications built the way an outside project would build one.
Styling slots and public parts
There are two independent styling routes. A node you place in a slot remains consumer-owned light DOM, so give it a class and style it with normal framework-scoped CSS. The layout, fallback, or state region created around that slot belongs to the component’s shadow root; style it only through a documented ::part() name or CSS custom property.
A bare text node is the exception to “give it a class”: text cannot be targeted directly. Use the host’s inherited typography or documented custom properties when that is the component contract—as with c2-mat-icon ligature text—or wrap the text in an element you own when it needs independent styling.
.message-copy { font-weight: 500; } /* assigned node */
c2-chat-message::part(content) { padding: 12px; } /* component-owned region */
Parts do not cross another shadow boundary. A content part can style the region containing a slotted c2-avatar, but cannot reach the avatar’s internals; use the avatar’s own public contract. In Vue SFCs, keep ::part() rules global because the scoped attribute cannot be added inside a component shadow root. Angular Emulated encapsulation has the same constraint: place host ::part() selectors in a global stylesheet (or opt that owning component out of emulated encapsulation), while ordinary classes on assigned nodes may remain local.
The support-inbox Vue example demonstrates both paths: thread__message-copy styles the assigned <p>, while the global .thread__message::part(content) rule styles its component-owned container without piercing the nested avatar.
Two rules hold everywhere:
- Register the elements at module scope, before the framework renders. Frameworks decide “attribute or property” by looking for the property on the element, and an element that has not upgraded yet has none — an object handed to
rowswould be stringified into an attribute. - Load the theme once.
import '@c2n/theme/theme.css'in the entry module.
Attributes and properties
Two conversions differ from stock Lit, and both exist so that one binding is correct on the server and on the client.
A boolean attribute written as "false" is false. Lit’s default converter is presence-based — any attribute that is there at all is true — so disabled="false" would enable the flag. Every c2n boolean reads the literal strings "false" and "0" as false instead; a bare disabled, or disabled="", is still true. This matters wherever a framework renders an attribute by stringifying its value, which Svelte 5 does for every name outside the HTML boolean list and every server renderer does for all of them.
It also gives c2-table-column a genuine third state: sortable and resizable inherit the table’s setting when the attribute is absent, and sortable="false" is how one column opts out.
An array or object property accepts a JSON string. rows, columns, items, options, series and steps parse JSON out of their attribute and out of a string assigned straight to the property, so JSON.stringify(rows) is one binding that works in both directions — the server writes it as an attribute, the client sets it as a property, both parse. Pass the array itself wherever the framework can write a property. A string that is not valid JSON is left alone rather than silently becoming undefined.
The ;-separated multi-value properties behave the same way: value="a;b" and element.value = 'a;b' both yield ['a', 'b'] on c2-list, c2-select, c2-tree and c2-virtual-list.
A camelCase property is not a camelCase attribute. Lit derives the attribute by lowercasing the property, so readOnly is the attribute readonly and maxLength is maxlength; where a component renamed one it is kebab-case, like row-key. The API tables and the manifests list the real attribute name, with the property spelling alongside it under fieldName. A lookalike such as rowkey is forwarded to the real attribute with a console warning rather than dropped.
Typed events
Every component that fires events declares an event map, so addEventListener narrows the detail with no cast:
import type { Table } from '@c2n/table'
const table = document.querySelector('c2-table') as Table
table.addEventListener('selection-change', (event) => {
event.detail.rows // TableRow[] — no cast
})The map is per element rather than global, because the DOM’s single HTMLElementEventMap cannot describe two components that fire the same name with different details: selection-change carries row keys on c2-table and option values on c2-list.
For code that is generic over elements — a React hook, a helper that awaits an event — EventMapOf reads a component’s map back out of its type:
import type { EventMapOf } from '@c2n/core/event-helper.js'
function on<T extends HTMLElement, Type extends keyof EventMapOf<T> & string>(element: T, type: Type, listener: (event: EventMapOf<T>[Type]) => void) {
element.addEventListener(type, listener as EventListener)
}Selection events do not bubble. Five components fire selection-change, so a bubbling one would reach a listener meant for whatever the component is nested in — a select inside a tab panel would look like a tab switch. Put the listener on the element itself.
React
Two packages, no wrapper. @c2n/<name>/react declares the tags in JSX; the props are derived from the element class, so a renamed property is a build error rather than a silent no-op.
// src/c2-elements.d.ts — one import per package you use
import '@c2n/table/react'
import '@c2n/select/react'// src/main.tsx — registration must happen before the first render
import '@c2n/theme/theme.css'
import '@c2n/table'
import '@c2n/table/table-column.js'React 19 assigns a prop as a property when the element has one, so rows={rows} lands as an object. That holds on the client only. On a server-rendered page — Next.js, React Router in SSR mode — React writes a custom element’s props into the HTML verbatim, the parser lowercases them, and hydration does not set properties: minWidth="240" reaches the element as minwidth, an attribute it does not declare. Write the kebab-case attribute name instead, min-width, expand-full, storage-key; the generated types list it next to the property. The component forwards the lowercase lookalike to the real attribute and warns in the console, so the value survives, but the warning is telling you to fix the spelling. An object or array prop stringifies on the server, so set it in an effect through a ref.
// Server-rendered: attributes by their markup name, objects through a ref
<c2-dash-card col="1" row="1" min-width="240" expand-full>Events are the exception on the client too: JSX has no spelling for onSelection-change, and React’s onChange is its own synthetic form-control event, so custom events — and input/change — go through a ref:
const tableRef = useRef<Table>(null)
useEffect(() => {
const table = tableRef.current
if (!table) return
const listener = (event: TableEventMap['selection-change']) => setSelected(event.detail.value)
table.addEventListener('selection-change', listener)
return () => table.removeEventListener('selection-change', listener)
}, [])Rendering a table cell with the framework
renderCell is handed to Lit, so it cannot return JSX, an Angular template or a Vue node — only a string, a DOM node, or a Lit template. For anything the framework should own, mark the column cell-slot: the table puts a <slot name="cell:<row key>:<field>"> in each cell of that column, and the app renders one child per row into the table’s light DOM.
<c2-table rows={rows} rowKey="symbol">
<c2-table-column field="change" header="Day" cellSlot />
{rows.map((row) => (
<span key={row.symbol} slot={`cell:${row.symbol}:change`} className={row.change >= 0 ? 'up' : 'down'}>
{formatDelta(row.change)}
</span>
))}
</c2-table>If the cell is better built as a Lit template, take html from @c2n/core/lit-helper.js rather than adding lit to the application — it is the same instance the components render with, so there is no version to pin by hand and no second copy of Lit in the bundle.
The children stay in the document’s light DOM, so ordinary CSS reaches them — a renderCell node lands inside the shadow root, where a stylesheet cannot, and has to be coloured through an inherited custom property. cell-slot needs row-key, and only the rows the virtualizer has rendered claim a child; the rest simply wait, so it suits paged and modest tables rather than a hundred thousand rows. renderCell, or the column’s own formatting, stays as the slot’s fallback.
Vue
One required piece of configuration, or every c2-* tag is resolved as a Vue component and renders nothing:
// vite.config.ts
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('c2-') } } })@c2n/<name>/vue registers the tags with Volar so templates can be checked strictly, and @c2n/framework-types/tsconfig.vue.json carries the compiler options that go with them:
{
"extends": ["./tsconfig.base.json", "@c2n/framework-types/tsconfig.vue.json"]
}Declare no vueCompilerOptions of your own alongside it — a local one replaces the inherited object rather than merging with it. What the fragment turns on:
strictTemplates, which is what makes the generated types do anything at all;experimentalModelPropName, sov-modelon a c2 control bindsvalue(orcheckedon the toggles) instead of themodelValueVolar assumes for a component.
Vue picks between a property and an attribute with key in el, so registering the elements before mount() is what makes plain bindings land as properties. Two escape hatches, both worth knowing:
.propforces a DOM property —:value.prop="[activeId]"for an array or object value;.attrforces an attribute, needed when a component does not reflect a property but styles it with:host([attr])—c2-chat-message’salignis that case, and is silently inert without it.
Kebab-case events bind by their real name (@selection-change), and the handler is typed from the component’s event map.
Angular
CUSTOM_ELEMENTS_SCHEMA on the component is the only configuration:
@Component({
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [FormsModule, ...C2_FORM_ACCESSORS],
template: `<c2-table [rows]="orders()" [rowKey]="'id'" (row-click)="handleRowClick($event)"></c2-table>`,
})[rows]="…" writes a property through setProperty, and (selection-change) / (row-click) bind kebab-case events by their real name. The trap is the reverse of React’s: a static attribute in a template stays an attribute, so rowKey="id" sets rowkey, which the component does not declare — use [rowKey]="'id'" or the real attribute name, row-key. The component forwards one of those lookalikes to the real attribute and warns in the console, instead of dropping the value in silence.
Angular’s built-in value accessors match input, select and textarea only, so a form-associated custom element is invisible to ngModel and reactive forms however correct its ElementInternals are. @c2n/angular supplies the missing half:
npm install @c2n/angularimport { C2_FORM_ACCESSORS } from '@c2n/angular'
// then in the template
// <c2-text-field [(ngModel)]="query" name="query"></c2-text-field>
// <c2-switch [(ngModel)]="openOnly" name="openOnly"></c2-switch>Because the schema turns off type checking for these tags, $event is a bare Event in the template. Take the component’s event-map type in the handler and cast once there, rather than $any at every call site.
Svelte 5 and SvelteKit
Register the elements at module scope before anything renders — the root +layout.svelte script, or a src/lib/c2n.ts it imports. Svelte decides between a property and an attribute with key in element, so a binding on a tag that has not upgraded yet quietly falls back to an attribute.
<script lang="ts">
import '@c2n/table'
import '@c2n/table/table-column.js'
import type { TableEventMap } from '@c2n/table'
let { orders } = $props()
let selected = $state<string[]>([])
</script>
<c2-table
row-key="id"
rows={orders}
sortable
onselection-change={(event: TableEventMap['selection-change']) => (selected = event.detail.rows.map((row) => row.id))}
>
<c2-table-column field="customer" header="Customer"></c2-table-column>
</c2-table>Events take the on prefix and the event’s real name, with no colon: onclick, oninput, onselection-change. Svelte 4’s on:selection-change still compiles but is deprecated. selection-change does not bubble, so the handler belongs on the element itself.
A route that server-renders its data hits both conversions above. Svelte writes running="false" rather than omitting the attribute, which now reads as false; and String(rows) is [object Object], so pass rows={JSON.stringify(orders)} — the server writes it as an attribute and the client sets the same string as a property, and both parse. Spell camelCase properties as the attribute the component declares (row-key, not rowKey).
bind:value applies to form elements and Svelte components, not to a custom element, so bind by hand. Every c2n form control re-emits the inner control’s native input and change, which is what makes that one line enough:
<c2-text-field value={draft} oninput={(event) => (draft = event.currentTarget.value)}></c2-text-field>There is no JSX-style declaration file to maintain, because Svelte accepts unknown elements. Point the editor at the generated html-custom-data.json below for attribute completion, and take event detail types from each package’s event map.
Editor support for templates TypeScript never sees
Plain .html, Angular templates and Vue SFCs get completion and documentation from two generated metadata files:
- VS Code / Volar — point
html.customDataat./node_modules/@c2n/framework-types/dist/html-custom-data.json; - WebStorm and the JetBrains IDEs — nothing to configure:
@c2n/framework-typesdeclaresweb-typesin itspackage.json.
Both are generated from the same custom-elements manifests as the API tables on this site, so they cannot drift from the components.
Lit and plain HTML
Import what you render at the top of the module and use the tags. Extend a component for a variant (class AppField extends TextField), re-emit child events with redispatchEvent from @c2n/core/dom-helper.js, and set child variables in static styles on :host or on a class.
<script type="module">
import '@c2n/theme/theme.css'
import '@c2n/button'
</script>
<c2-button>Save</c2-button>“Lit is in dev mode”
A dev server — Next.js, Vite, the Angular CLI — logs Lit is in dev mode. Not recommended for production! once per
page. That is expected: Lit publishes a development export condition with extra checks and clearer warnings, and
dev servers resolve it. A production build resolves the default condition and the message is gone. There is nothing to
configure, and silencing it in development would also silence the warnings it exists for.
Duplicate registration
customElements.define throws when a tag is already taken, which takes down the page. A second definition of a c2 element warns and keeps the first instead — so two versions of a package in one dependency tree, two micro-frontends, or a hot reload do not break the app. Fix the duplicate when you see the warning; the two copies are still two different classes.