ComponentsIconsGuidesExamplesGetting started
GitHub repository

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 rows would 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, so v-model on a c2 control binds value (or checked on the toggles) instead of the modelValue Volar 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:

  • .prop forces a DOM property — :value.prop="[activeId]" for an array or object value;
  • .attr forces an attribute, needed when a component does not reflect a property but styles it with :host([attr])c2-chat-message’s align is 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/angular
import { 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.customData at ./node_modules/@c2n/framework-types/dist/html-custom-data.json;
  • WebStorm and the JetBrains IDEs — nothing to configure: @c2n/framework-types declares web-types in its package.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.

escAccordionConnected, animated panels with shared borders and single or multiple expansion.LayoutArea chartA line chart with the region under each line filled — every line-chart attribute, plus a fill opacity.ChartAttachmentFile and image attachments with metadata, upload progress, failure states, and actions.Data displayAutocompleteSearchable combobox for local or remote items with customizable list rows.InputsAvatarImage, initials or icon for a person, with status dot and badge.Data displayBadgeTinted pill for status text, counts and dots, optionally pinned to a corner of another element.Data displayBar chartBars over categories or time buckets, with several series drawn side by side within each band.ChartBorder BeamA decorative beam that travels around the border of any positioned container.LayoutBreadcrumbNavigation trail of link buttons with separators, a current page and optional collapsing.NavigationButtonThemeable button with slots for text, prefix, suffix and running icons.ButtonsButton GroupJoined actions and polished segmented controls with single or multiple selection.ButtonsCandlestick chartAn ECharts OHLC chart for market sessions and other open-close ranges, with semantic positive and negative colours.ChartCardGroups related content and actions on a single bordered surface.LayoutCascaderSelect a value from related, multi-level data in one floating panel.InputsChat InputAuto-growing message composer with keyboard submission, toolbar actions and native form support.ChatChat MessageFlexible message row for conversations, assistant answers and activity updates.ChatCheckboxNative checkbox behaviour in a quiet, themeable box with an opt-in hover layer.InputsCode EditorEditable, syntax-highlighted source field on CodeMirror 6, themed entirely through CSS variables.InputsCode ViewerSyntax-highlighted code with line numbers, copy button and dark mode, powered by shiki.Data displayColor AreaTwo-dimensional area for picking saturation and value of a colour.InputsColor SelectColour swatch that opens a full picker built from area and slider.InputsColor SliderHorizontal slider for choosing a hue from 0 to 360.InputsCopy ButtonButton that copies text to the clipboard — the element it sits in, another element by id, or a literal string.ButtonsDashboardGrid of resizable panes, dragged by the edges they share.LayoutDate InputForm-associated single-date input with native picker, constraints, helper text and error states.InputsDate SelectorAccessible one- or two-month calendar for choosing a date range.InputsDetailsCollapsible disclosure built on native details and summary.LayoutGauge chartA focused radial KPI gauge drawn by ECharts, with a configurable scale, progress arc and pointer.ChartHeaderArranges brand, navigation, actions and a mobile trigger in a reusable site shell.LayoutIcon ButtonRound, hoverable button wrapping a slotted SVG icon.ButtonsKbdKeyboard key label for shortcuts and command hints, with the semantics of the native kbd element.Data displayLabelCaption that names and activates the control referenced by its for attribute, with a required marker.InputsLine chartA line chart over a time or numeric x axis, drawn on canvas by uPlot, with series declared as children.ChartLink ButtonText-styled control for link and navigation actions, rendered as a real anchor or a button.NavigationListVertical list container with single or multiple selection.Data displayList ItemSelectable row with icon slots, used on its own or as the option of list and select.Data displayMenuCommands, links, checkboxes and submenus in a popover anchored to a trigger.NavigationModalDialog built on the native dialog element: focus trap, backdrop, Escape, title, body and footer.FeedbackNavigation MenuSite navigation bar whose triggers open panels of links below the header.NavigationNumber InputForm-associated numeric input with native validation, step controls, adornments and helper states.InputsOverlayAnchored popup built on the browser Popover API, positioned with floating-ui.FeedbackPaginationPage navigation in three layouts: numbered pages, a simple page status, or a table-footer row with rows-per-page.NavigationPie chartA pie or donut chart drawn by ECharts, where one row is one slice and label-field names it.ChartProgressLinear progress bar, indeterminate or filling to a value, with an optional label and count.FeedbackQR CodeGenerate accessible, themeable QR codes locally as crisp SVG graphics.Data displayQuestionnaireA multi-step single- and multiple-choice flow with validation, shortcuts and form submission.InputsRadar chartCompare several profiles across the same set of normalized indicators.ChartRadioRadio options built on native inputs, grouped into one value with keyboard navigation.InputsRateAccessible star rating input with hover preview, keyboard control and optional half values.InputsReorder ListReorder a vertical queue with pointer or keyboard input while the application owns persistence.Data displayScatter chartAn ECharts scatter plot for finding relationships, clusters and outliers across two numeric measures.ChartSelectDropdown that pairs a themeable trigger with an anchored list of c2-list-item options.InputsSeperatorHorizontal or vertical rule with an optional label, for dividing content and toolbars.LayoutSheetDialog pinned to an edge of the screen, for content that complements the page rather than interrupting it.FeedbackSide NavResponsive navigation drawer beside the page: pushes the content on large screens, slides over it with a backdrop on small ones.NavigationSkeletonPlaceholder block standing in for content that has not arrived, in three shapes and three animations.FeedbackSliderRange input with a themeable track, thumb, step ticks and value bubble.InputsSparklineA chromeless trend line sized for a table cell, a list row or the trend slot of a c2-stat.ChartSpinnerCircular progress indicator, indeterminate or showing a value, with optional text.FeedbackStatDisplays a KPI with an optional icon, trend and supporting description.Data displayStatus PanelCommunicate empty states, operation outcomes and recoverable errors with clear next steps.FeedbackStepsA vertical trace of a task as it runs: statuses, durations, and stages that open while they work and close when they are done.Data displaySwitchOn/off toggle on a native switch input, with label, description and thumb icons.InputsTableVirtualized data grid with declarative columns, sorting, selection, pinning and resizing.Data displayTabsTab strip that shows one content panel at a time.NavigationText FieldSingle-line input with icon slots, clear button, helper and error text, and a character counter.InputsTextareaMultiline text input with resizing, helper and error text, and a character counter.InputsTheme SelectColour-theme switcher: click to step to the next mode, hover for the full menu.InputsToastNotification cards and a manager for stacked, queued notifications with independent lifetimes.FeedbackTooltipContextual hint shown when its target is hovered or focused, rendered in the top layer.FeedbackTreeHierarchical tree view with expansion, selection, checkboxes and lazy loading.Data displayUploadDrag-and-drop file selection with validation, upload progress, retry, cancellation, and attachment results.InputsVirtual ListWindowed list with built-in search, sorting, selection and an async data source.Data displayFeather Icons287 open-source Feather icons, one web component each.IconsMat Icon2,234 Material Icons ligatures rendered through a single element.IconsPhosphor Icons1,512 flexible icons in six weights, one web component each.IconsThemingTheme every c2n component from a handful of design tokens with @c2n/theme, or reach for any component variable directly.GuideFrameworksUse 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.GuideUsing c2n in an applicationThe workflow behind every c2n app: load the theme once, use c2-* tags directly, and turn every repeated pattern into a small variant or composed component.GuideAI toolsGive Claude Code, Codex or Google Antigravity the c2n skill, live component APIs, examples and application conventions.Guide