Plain HTML and other frameworks
Every component as a custom element (<tf-button>, <tf-data-table> …) for plain HTML, Vue, Svelte, Angular, Astro and server templates. No React setup needed.
@twentyfirstmedia/elements turns every component into a custom element with the same props: Button is <tf-button>, DataTable is <tf-data-table>, UIProvider is <tf-provider>. React is bundled inside, so the page needs no build step and no React tooling. The elements use no shadow DOM: the Halo stylesheet styles them like everywhere else.
From a CDN
Load the Halo CSS and the elements, then write HTML:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@twentyfirstmedia/halo/dist/styles.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@twentyfirstmedia/elements/dist/elements.js"></script>
<tf-panel title="Revenue" subtitle="Last 7 days">
<tf-button slot="action" variant="ghost" size="sm">Export</tf-button>
<tf-bar-chart label="Revenue per day"
data='[{"label":"Mon","value":32},{"label":"Tue","value":48},{"label":"Wed","value":41}]'>
</tf-bar-chart>
</tf-panel>
unpkg works the same way (https://unpkg.com/@twentyfirstmedia/elements/dist/elements.js). For a classic <script> without type="module" use dist/elements.global.js; it exposes window.TwentyfirstElements with toast and the Intl formatters. Pin a version in production, for example @twentyfirstmedia/elements@0.1.
Self-host when you can: the CDN is the only external request. The components themselves never load anything, and the fonts come with the stylesheet.
From npm
npm install @twentyfirstmedia/elements @twentyfirstmedia/halo
import "@twentyfirstmedia/halo/styles.css";
import { toast } from "@twentyfirstmedia/elements"; // defines every tf-* element
The @twentyfirstmedia/halo package is only needed for its stylesheet; the elements bring their own copy of the components and React.
Attributes, properties, events and slots
| Component API | As an element |
|---|---|
variant="ghost", size="sm" | the same attribute |
defaultValue, iconRight | kebab-case: default-value, icon-right |
disabled, selectable (boolean) | present means true, absent means unset, "false" means false |
max={8} (number) | max="8" |
items, columns, rows (arrays, objects) | a property, el.items = [...], or JSON in the attribute: items='["Day","Week"]'; lists of strings also take items="Day, Week" |
format, renderExpanded, onVerify (functions) | properties only: el.format = (v) => ... |
onChange, onDecide, onClose | DOM events change, decide, close (the name without on, lowercase) |
children | the element's content: text, HTML and other tf-* elements |
action, trace, corner (ReactNode props) | children with slot="action"; a DOM node set as a property works too |
actions (a list of nodes) | every child with slot="actions" is one item |
panels on Tabs (a record of nodes) | children with slot="panels:Overview" |
className, style | the class and style attributes |
Events bubble, are composed and can be cancelled. event.detail holds the callback's argument, or an array when the callback takes several (decide gives [key, text]). Where a component would pass a React change event, the detail is the control's value (checked for switches and checkboxes).
const tabs = document.querySelector("tf-tabs");
tabs.addEventListener("change", (e) => console.log("now on", e.detail));
const card = document.querySelector("tf-approval-card");
card.addEventListener("decide", (e) => {
const [key, text] = e.detail;
card.decision = { key, by: "Mara", at: "09:41", text };
});
A few rules make plain HTML pleasant:
- Uncontrolled by default. Without
valueorchecked, Tabs, Segmented, Switch and friends keep their own state. Set the attribute to control them: the element then follows the user's input like a native control, unless a listener callsevent.preventDefault(). - Optional callbacks follow listeners. Some components only show a part when the callback is set, such as the dismiss button of
<tf-alert>. The element passes those callbacks while something listens for the event on the element or a tf-* element around it. - Closing hides the element. Modal, Drawer, CommandPalette and Alert set the
hiddenattribute afterclose(ordismiss) unless the event is cancelled. Removehiddento show them again.<tf-modal>opens as a dialog over the page: focus moves in and stays there, Escape and the backdrop close it, and focus returns to where it was. - Menus close their popover. A
<tf-menu>inside<tf-popover>closes it when an item runs or on Escape. - Nested elements share context. A tf-* element inside another one renders in its parent's React tree, so
<tf-text-field name="email">inside<tf-form>shows the form's validation message. - Layout is unchanged. Each element is
display: contents; the component's root is the flex or grid item. Theidattribute stays on the element, andtitle,roleandlangmove into the component.
Content is read when the element first renders; children added later with append are picked up as well. Content that is not shown at the moment (a closed popover, a hidden modal) stays in the document, inside the element, so getElementById keeps working.
Toasts and helpers
<tf-toaster></tf-toaster>
<script type="module">
import { toast, formatMoney } from "https://cdn.jsdelivr.net/npm/@twentyfirstmedia/elements/dist/elements.js";
toast({ text: `Paid ${formatMoney(1290)}`, tone: "success" });
</script>
Kit switching and theming
With one kit's styles.css no attribute is needed. To switch between Halo and Pro at runtime, load @twentyfirstmedia/ui-core/dist/themes.css (both kits) and set data-kit on <html>. Theme and density work the same way in both setups:
<html data-kit="halo" data-theme="system">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@twentyfirstmedia/ui-core/dist/themes.css">
<script type="module">
document.documentElement.dataset.kit = "pro";
document.documentElement.dataset.theme = "dark";
</script>
<tf-provider kit="halo" theme="dark" density="compact"> does the same declaratively; with scope="subtree" it applies to its content only. Every colour, radius, shadow and spacing is a CSS variable, so your own CSS can retheme the kit (see Theming and tokens):
:root {
--primary: var(--lilac-ink);
--radius-lg: 12px;
}
Vue
Tell the compiler that tf-* tags are custom elements. Vue binds arrays and objects as properties and listens with @:
// vite.config.js
import vue from "@vitejs/plugin-vue";
export default {
plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith("tf-") } } })],
};
<script setup>
import "@twentyfirstmedia/elements";
import { ref } from "vue";
const tab = ref("Overview");
</script>
<template>
<tf-tabs :items="['Overview', 'Activity']" :value="tab" @change="tab = $event.detail" />
</template>
Svelte
<script>
import "@twentyfirstmedia/elements";
let checked = $state(false);
</script>
<tf-switch checked={checked} onchange={(e) => (checked = e.detail)}>Email me updates</tf-switch>
<tf-data-table columns={columns} rows={rows} selectable></tf-data-table>
Angular
Allow custom elements in the component and bind properties with [ ] and events with ( ):
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import "@twentyfirstmedia/elements";
@Component({
selector: "app-team",
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<tf-tabs [items]="tabs" (change)="onTab($event)"></tf-tabs>
<tf-data-table [columns]="columns" [rows]="rows" selectable></tf-data-table>
`,
})
export class TeamComponent {
tabs = ["Members", "Invites"];
tab = "Members";
columns = [{ key: "name", label: "Name" }];
rows = [{ id: 1, name: "Ava Vogel" }];
onTab(e: Event) {
this.tab = (e as CustomEvent<string>).detail;
}
}
Astro, Rails, Django and other server templates
Render the tags on the server and load the elements once in the layout. Pass data as JSON in attributes:
---
// Astro
const items = ["Overview", "Activity"];
---
<tf-tabs items={JSON.stringify(items)}></tf-tabs>
<script>
import "@twentyfirstmedia/elements";
</script>
<!-- Django: escape the JSON for the attribute -->
<tf-bar-chart label="Signups" data="{{ signups_json }}"></tf-bar-chart>
Static markup with the kit classes
For markup that needs no behaviour, the kit classes work without any script: the stylesheet alone is enough.
<section class="tf-panel">
<div class="tf-row">
<button class="tf-btn tf-btn-primary" type="button">Save</button>
<button class="tf-btn tf-btn-ghost" type="button">Cancel</button>
<span class="tf-badge tf-badge-success"><span class="tf-badge-dot"></span>Live</span>
</div>
</section>
Interactive components (tabs, menus, dialogs, tables) need the elements for their keyboard paths and ARIA.
For UI over a map, a video or photos, class="tf-glass" turns a surface into glass, and data-surface="glass" on a container turns everything floating inside into glass (see Glass):
<div data-surface="glass" style="position: relative">
<canvas id="map"></canvas>
<section class="tf-panel" style="position: absolute; top: 16px; left: 16px">Layers</section>
</div>
Editor support and types
The package ships a Custom Elements Manifest (custom-elements.json) for autocompletion of tags, attributes, events and slots, and VS Code HTML data:
{ "html.customData": ["./node_modules/@twentyfirstmedia/elements/dist/vscode.html-custom-data.json"] }
In TypeScript, document.querySelector("tf-data-table") is typed with every property of DataTable.