C&C Docs
Log in
Widgets

Writing a widget

A widget is a custom element of your own. It lives in a folder under widgets/: an .html template, and beside it, if it needs them, a .mjs controller and a .css file. Write its tag on a page and the template expands in place. This page builds two small ones, t-badge and t-card.

The template

The file name is the tag name. Its <host> element stands for the tag itself: its classes land on <t-badge>, and the rest is ordinary template markup.

<!-- widgets/t/badge/t-badge.html -->
<host
    :class="muted ? 'bg-neutral-200 text-neutral-500' : 'bg-primary-100 text-primary-800'"
    class="">
  <slot><span x-text="label"></span></slot>
</host>
Using it
<t-badge label="Beta"></t-badge>
Result
Beta

Props

Each key of the controller's default export is a prop, and its default value declares the type. An attribute sets it, a :-binding sets it from the page's scope, and a $-binding shares one value with the page, both ways. muted="false" arrives as false, not as a string.

// widgets/t/badge/t-badge.mjs
export default {
  label: '',
  muted: false
}
Setting props
<div class="" x-data="{ topic: 'Widgets', quiet: true }">
  <t-badge label="an attribute"></t-badge>
  <t-badge :label="topic"></t-badge>
  <t-badge label="muted" muted></t-badge>
  <t-badge label="muted=false" muted="false"></t-badge>
  <t-badge label="$muted" $muted="quiet"></t-badge>
  <button class="" @click="quiet = !quiet">toggle quiet</button>
</div>
Result
an attribute Widgets muted muted=false $muted

Slots

A <slot> is where the content written between the tags goes; <slot name="title"> takes the child marked slot="title". What a slot holds is shown when nothing fills it. t-card also uses t-badge: widgets compose like any other element.

<!-- widgets/t/card/t-card.html -->
<host>
  <header class="bar">
    <span class="flex items-center gap-2">
      <slot name="title">Untitled</slot>
      <t-badge :label="badge" x-if="badge"></t-badge>
    </span>
    <button @click="pick()">pick</button>
  </header>
  <div class="body"><slot>No content.</slot></div>
</host>
Filling the slots, and leaving them empty
<div class="grid gap-4">
  <t-card badge="new">
    <span slot="title">Quarterly report</span>
    The body fills the default slot.
  </t-card>
  <t-card></t-card>
</div>
Result
Quarterly report new
The body fills the default slot.
Untitled
No content.

The controller

The controller is the widget's state and methods. Methods run where the template calls them; $dispatch sends an event the page listens to with @pick. A mount() runs in the browser only, for work that needs the real element.

// widgets/t/card/t-card.mjs
export default {
  name: '',
  badge: '',

  pick() {
    this.$dispatch('pick', { name: this.name })
  },

  mount() {
    this.$el.setAttribute('data-mounted', '1')
    return () => this.$el.removeAttribute('data-mounted')
  }
}
Listening to the widget
<div class="grid gap-3" x-data="{ picked: 'nothing yet' }">
  <t-card name="alpha" @pick="picked = $event.detail.name">
    <span slot="title">Alpha</span>
    Press pick.
  </t-card>
  <t-card name="beta" @pick="picked = $event.detail.name">
    <span slot="title">Beta</span>
    Or this one.
  </t-card>
  <p class="text-sm">Picked: <b x-text="picked"></b></p>
</div>
Result
Alpha
Press pick.
Beta
Or this one.

Picked: nothing yet

Scoped styles

The .css beside the template applies to this widget only. :host is the element itself, and @apply pulls in the same utilities the templates use. A page with its own .bar class is untouched.

/* widgets/t/card/t-card.css */
:host {
  @apply block rounded-lg border border-primary-200 bg-white;
}

.bar {
  @apply flex items-center justify-between gap-2 border-b border-primary-100 px-4 py-2 font-semibold;
}

.body {
  @apply px-4 py-3 text-primary-700;
}