Frameworks integration
Use Tsunami in React, Vue, Svelte and Django. There are no wrapper components: the markup from these docs goes straight into your templates.
Load it once
Tsunami is a stylesheet and a script, not a component library, so there is nothing to wrap. Install it, import both files once, and write the markup from these docs in your templates. The script starts each component when it appears in the page, including markup your framework adds later.
npm install @djirasak/tsunami-ui
pnpm add @djirasak/tsunami-ui
yarn add @djirasak/tsunami-ui
bun add @djirasak/tsunami-ui
import "@djirasak/tsunami-ui/css";
import "@djirasak/tsunami-ui";
The theme can flash. A bundler loads the import as a module, after the first frame, so a reader who chose dark sees one light frame (measured in React, Vue and Svelte). Either put this one line in <head> of your index.html, before everything else, or load the script as a plain <script> there instead.
<script>try{var t=localStorage.getItem("tsunami-theme");if(t==="dark"||t==="light")document.documentElement.dataset.theme=t}catch(e){}</script>
The plain script: copy dist/tsunami.min.js, dist/tsunami.min.css and the fonts/ and flags/ folders from node_modules/@djirasak/tsunami-ui into your public folder, and load the script in <head>. Copy fonts/ too: the stylesheet refers to ../fonts/, and without it the font requests get your app's HTML page back.
TypeScript. Importing the package types window.tsunami and the tsunami:* events. If you load the plain script and import nothing, add /// <reference types="@djirasak/tsunami-ui" /> to a .d.ts file. In version 1.2.0, import "@djirasak/tsunami-ui/css" reports TS2882: add declare module "@djirasak/tsunami-ui/css"; in a file of its own (later versions include it).
Tested with
| Framework | Version | Tested in |
|---|---|---|
| React | 19.3 with Vite 8, TypeScript 7 | dev, production build, StrictMode |
| Vue | 3.5 with Vite 8, TypeScript 6 | dev, production build, v-if, v-show, KeepAlive, Transition |
| Svelte | 5.57 with Vite 8, and SvelteKit 3.1 | dev, production build, server rendering |
| Django | 6.1 on Python 3.12, with HTMX | runserver, collectstatic, whitenoise |
Each was checked with a small app in Chromium: buttons and badges, a dialog opened by commandfor, tabs, a toast, a data-validate form, a tag input and a select that mount and unmount, the theme switcher, and listening to tsunami:* events. Other browsers, and the heavier components (chart, editors), were not part of these checks.
React
Use className for the class and the data-* attributes as written. Buttons that open a dialog need the lowercase commandfor and command: React 19 warns about commandFor, and TypeScript does not know either name until you add them.
// src/env.d.ts
import "react";
declare module "react" {
interface ButtonHTMLAttributes<T> {
commandfor?: string;
command?: string;
}
}
<button type="button" className="btn" commandfor="dlg" command="show-modal">
Open
</button>
Events. JSX has no onTsunami… props. Listen in an effect, and remove the listener in its cleanup (StrictMode runs effects twice). Toasts are created by the script, so their events are heard on document.
useEffect(() => {
const onClose = (e: DocumentEventMap["tsunami:toastclose"]) =>
setReason(e.detail.reason);
document.addEventListener("tsunami:toastclose", onClose);
return () => document.removeEventListener("tsunami:toastclose", onClose);
}, []);
A select you set from state. React sets the native value without a change event, so the styled Select keeps its old label. Tell it to repaint:
const wrap = useRef<HTMLDivElement>(null);
useEffect(() => {
window.tsunami.select.refresh(wrap.current!);
}, [value, options]);
Replacing an enhanced element. If a conditional swaps one select for another at the same place, React reuses the node and the styled control keeps stale attributes. Give the changed part a key. The same goes for a tag input: it reads data-value once, when it appears, so use a key to start it again or tsunami.tagInput.set(el, ["a", "b"]) to change it.
A data-validate form. Tsunami checks the form before your onSubmit runs and stops it when a field is invalid. In version 1.2.0 your handler ran first: start it with if (!window.tsunami.validate.check(form)) return;. Controlled inputs keep working next to it.
Vue
Attributes pass straight through to the DOM: commandfor, command, closedby, popovertarget, inert and every data-*. You need no isCustomElement setting. Leave vueCompilerOptions.strictTemplates off, or vue-tsc rejects those attributes.
Events. A template listener works on an element Vue renders, which is the target of the event or an ancestor of it. Annotate the argument yourself, because template handlers are untyped. Events from elements the script creates, such as a toast, are heard on document.
<div class="tag-input" data-name="labels" data-value="design,billing"
@tsunami:tagschange="onTags"></div>
function onTags(e: CustomEvent<{ value: string[] }>) {
tags.value = e.detail.value;
}
onMounted(() => document.addEventListener("tsunami:toastclose", onClose));
onBeforeUnmount(() => document.removeEventListener("tsunami:toastclose", onClose));
A select you set from v-model. Repaint it after Vue has updated the DOM:
await nextTick();
window.tsunami.select.refresh(wrapperEl);
v-if, v-show, a changing :key, a reordered v-for, <Transition> and <KeepAlive> all leave one copy of each component. A tag input starts again from data-value when it is remounted, so bind :data-value="tags.join(',')" to keep what the reader typed, and use tsunami.tagInput.set to change it later. A data-validate form runs the check before @submit handlers; in version 1.2.0 call window.tsunami.validate.check(form) first.
Svelte and SvelteKit
commandfor and command are accepted by Svelte 5's types, and bind:value keeps working next to a data-validate form.
Events. Use an event attribute with the colon name. Svelte warns about the colon, and TypeScript does not know the name until you add this file:
// src/tsunami.d.ts
type TsunamiHandlers = {
[K in keyof TsunamiEventMap as `on${K}`]?: (e: TsunamiEventMap[K]) => void;
};
declare module "svelte/elements" {
interface HTMLAttributes<T> extends TsunamiHandlers {}
}
export {};
<!-- svelte-ignore attribute_illegal_colon -->
<div class="tag-input" data-name="labels" ontsunami:tagschange={onTags}></div>
Events from elements the script creates, such as a toast, are heard with document.addEventListener in an $effect.
A select you bind. The styled Select is built when the element is inserted, before Svelte applies the bound value, and it is not repainted when the value changes later. Repaint it:
let selWrap: HTMLElement;
$effect(() => {
selVal; // run again when it changes
if (selWrap) window.tsunami.select.refresh(selWrap);
});
Put bind:this={selWrap} on the element that holds the select. In a keyed {#each} reorder, a tag input keeps its tags in versions after 1.2.0; in 1.2.0 any element added to the page started every tag input again. A data-validate form runs its check before your onsubmit; in 1.2.0 start the handler with if (!window.tsunami.validate.check(form)) return;.
SvelteKit. Server rendering works, with no hydration warnings. Load the script in src/app.html before %sveltekit.head%, and import the CSS in +layout.svelte. The script has already started the components in the server-rendered markup when Svelte hydrates it, and hydration leaves them alone.
<!-- src/app.html -->
<script src="%sveltekit.assets%/tsunami.min.js"></script>
%sveltekit.head%
<!-- src/routes/+layout.svelte -->
<script>
import "@djirasak/tsunami-ui/css";
</script>
Django
Django renders the HTML on the server, which is how Tsunami is meant to be used: write the markup in a template and the script starts it in the browser. HTMX works with no extra code: a fragment that holds a select, a tag input, a date picker or a dialog starts as soon as it is swapped in, and repeated swaps leave no duplicates.
Serve the package from static. Point STATICFILES_DIRS at the package root, not at dist/: the stylesheet refers to ../fonts/ and ../flags/, and with only dist/ mapped the fonts and flags return 404.
# settings.py
STATICFILES_DIRS = [BASE_DIR / "npm/node_modules/@djirasak/tsunami-ui"]
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
{% load static %}
<link rel="stylesheet" href="{% static 'dist/tsunami.min.css' %}">
{# only if a page has a .date-picker, and before tsunami: #}
<script src="{% static 'dist/vanilla-calendar.min.js' %}"></script>
<script src="{% static 'dist/tsunami.min.js' %}"></script>
Put the scripts in <head> in your base template, as plain scripts, so the saved theme applies before the first paint.
In production use collectstatic with whitenoise's hashed storage. With version 1.2.0 the vendored scripts end with a comment for a source map that is not in the package, and ManifestStaticFilesStorage stops on it. Restrict the post-processing to the stylesheets (versions after 1.2.0 have no such comments):
# demo/storage.py
from django.contrib.staticfiles.storage import ManifestStaticFilesStorage
from whitenoise.storage import CompressedManifestStaticFilesStorage
class TsunamiStorage(CompressedManifestStaticFilesStorage):
patterns = tuple(p for p in ManifestStaticFilesStorage.patterns if p[0] == "*.css")
Forms. Render each Django field with Tsunami's markup: the input class on the control, aria-invalid and aria-describedby when the field has errors, and the errors in a small with data-error. One template tag and one include do it for every form:
# templatetags/ts.py
from django import forms as w
from django import template
from django.template.loader import render_to_string
register = template.Library()
@register.simple_tag
def tsfield(bf):
widget = bf.field.widget
attrs = {}
if not isinstance(widget, (w.CheckboxInput, w.RadioSelect, w.FileInput, w.HiddenInput, w.Select)):
attrs["class"] = "input"
ids = []
if bf.help_text:
ids.append(f"{bf.auto_id}_hint")
if bf.errors:
attrs["aria-invalid"] = "true"
ids.append(f"{bf.auto_id}_error")
if ids:
attrs["aria-describedby"] = " ".join(ids)
return render_to_string("ts/field.html", {
"bf": bf,
"control": bf.as_widget(attrs=attrs),
"check": isinstance(widget, w.CheckboxInput),
"hint_id": f"{bf.auto_id}_hint",
"error_id": f"{bf.auto_id}_error",
})
{# ts/field.html #}
<div class="field">
{% if check %}{{ control }}<label for="{{ bf.id_for_label }}">{{ bf.label }}</label>
{% else %}<label for="{{ bf.id_for_label }}" id="{{ bf.auto_id }}_label">{{ bf.label }}</label>{{ control }}{% endif %}
{% if bf.help_text %}<small id="{{ hint_id }}">{{ bf.help_text }}</small>{% endif %}
<small data-error id="{{ error_id }}"{% if bf.errors %} role="alert"{% endif %}>{% if bf.errors %}{{ bf.errors|join:" " }}{% endif %}</small>
</div>
{% load ts %}
<form method="post" novalidate>
{% csrf_token %}
{% for bf in form %}{% tsfield bf %}{% endfor %}
<button type="submit" class="btn" data-variant="primary">Save</button>
</form>
Server-side errors show after a failed POST and the values are kept. To add client-side checking, put data-validate on the form: a valid form still posts, and an invalid one never leaves the page. Tsunami reuses the small[data-error] you rendered, so nothing is duplicated, and it clears a server error as the reader fixes the field. Keep Django's novalidate on a form that does not use data-validate, or the browser's own bubble blocks the submit.
Rich inputs. A Select, a Tag input and a Date picker are made of a few elements, so give each a small widget template that renders the markup from its docs page. The value reaches request.POST under the right name: a select as its option value, a tag input as "design,billing" and a date picker as an ISO date (2026-12-08), which Django's DateField accepts. Values from form.initial show as preselected. Django templates cannot read an attribute such as data-max, so flatten the attributes in the widget's get_context.
Messages as toasts. Render messages into a hidden list and turn each into a toast from a script file (an inline script is blocked by a strict content security policy):
{% if messages %}
<div id="dj-messages" hidden>
{% for m in messages %}<p data-level="{{ m.level_tag }}">{{ m }}</p>{% endfor %}
</div>
<script src="{% static 'demo/messages.js' %}"></script>
{% endif %}
// demo/static/demo/messages.js
var map = { success: "ok", error: "danger", warning: "warning", info: "info", debug: "neutral" };
document.querySelectorAll("#dj-messages [data-level]").forEach(function (p) {
tsunami.toast({ variant: map[p.dataset.level] || "neutral", title: p.textContent });
});
Tsunami itself needs no content security policy exceptions: with script-src 'self' and style-src 'self' the select, tag input, date picker, toast, dialog and table sorting ran with no violations.
Things to know in every framework
| If | Then |
|---|---|
| You set a Select's value from code or state | Call tsunami.select.refresh(wrapper) afterwards. The styled control repaints on a change event, and setting value fires none. |
You change a tag input's data-value later | It is read once, when the tag input appears. Use tsunami.tagInput.set(el, [...]), or remount it with a key. |
| Your framework reuses a node that Tsunami built something around | Give that part a key, so a new element replaces it. |
You use data-validate and your own submit handler | Versions after 1.2.0 run the check first and stop an invalid form. In 1.2.0, call tsunami.validate.check(form) first. |
| The first click on Submit seemed to do nothing | Fixed after 1.2.0: each field now keeps a line for its error. Before, the first error pushed the button away between mouse down and up. |
| You see a flash of the light theme | The script ran too late. Put it in <head> as a plain script, or add the one-line theme script. |
| Fonts or flags are missing | The stylesheet refers to ../fonts/ and ../flags/: keep them next to dist/. |