Scripting
TriFrost lets you attach client-side behavior directly inside your serverside JSX markup using <Script>
. No bundlers, no hydration wrappers, no scope leaks.
It's SSR-native, CSP-safe, and supports fine-grained interactivity without shipping full-page JS. If you're used to React + client bundles, TriFrost scripting will feel both simpler and more powerful.
π Scripts are server-rendered, but client-executed. Think of
<Script>
as a secure, typed replacement for<script>
tags, scoped to the DOM node theyβre written inside.β Works with or without the Atomic Runtime. Atomic adds reactivity, global state and utilities, but isn't required.
π§° Defining Your Script System
Create your scripting engine using createScript()
and place it in a shared file (e.g. our recommendation script.ts
).
// src/script.ts
import {createScript} from '@trifrost/core';
import {css} from './css';
import {type Env} from './types';
type RelayEvents = {
/**
* These are the messages you can publish and subscribe to between VMs
* See: JSX Atomic Runtime
*/
};
type StoreData = {
/**
* Global store keys accessible via $.storeGet / $.storeSet
* See: JSX Atomic Runtime
*/
};
const config = {
atomic: true, /* Enables TriFrost Atomic reactivity + scoped utilities */
css, /* Enables certain behaviors in TriFrost Atomic to be typed (such as $.cssVar and $.cssTheme) */
} as const;
export const {Module, Script, script} = createScript<
typeof config,
Env, /* Env ensures that when you do eg script.env it is fully typed */
RelayEvents,
StoreData,
>(config);
β οΈ You should only ever call
createScript()
once per environment. Define it in one file and reuse the exported<Script>
andscript
across your app.π RelayEvents/StoreData? Learn about these in the TriFrost Atomic Runtime doc
Type Safety
Just like with styling, TriFrost scripts are fully typed end-to-end:
data={...}
is strongly typed and inferredel
is typed asHTMLElement
(and some extensions if running on TriFrost Atomic)- Atomic
$
utilities are fully typed
<Script data={{count: 1}}>{({data}) => {
data.count.toFixed(); // β
type-safe
}}</Script>
This lets you build powerful UI without schema validation or runtime type-checking.
π Registering in the App
To hydrate scripts, pass your instance to the app config:
import {App} from '@trifrost/core';
import {css} from './css';
import {script} from './script';
import {type Env} from './types';
const app = new App<Env>({
client: {css, script},
});
This ensures:
- Global styles are emitted at build time (
/__atomics__/client.css
) - Per-request styles are injected automatically
- Tokens, themes, and resets are registered exactly once
- Nonces are automatically respected
You never need to call script.root()
manually, TriFrost handles that automatically.
π§ Prefer a guided setup?
You can skip the above manual steps and let the CLI scaffold everything for you, including runtime setup, middleware, styling, and more.
Run:
# Bun
bun create trifrost@latest
# NPM
npm create trifrost@latest
... giving you a fully functional project in under a minute.
β¨ What is <Script>
The <Script>
component is TriFrostβs universal way to attach logic to your HTML:
- β Inline behavior via serialized function calls
- β External script tags with full CSP/nonce support
- β Built-in deduplication
- β
Optional atomic reactivity when using
atomic: true
in the config for createScript
π Learn about the TriFrost Atomic Runtime to craft reactive masterpieces.
β¨ What is a Module
Modules are globally scoped behaviors, declared using Module(...)
and registered via createScript({modules})
.
They are not tied to DOM elements, and are ideal for:
- Modals
- Notifications
- Global effects
- Keyboard listeners
- Audio players
- App-wide logic
- ...
To define one:
// Modal.ts
import { Module } from '~/script';
export function Modal () {
return Module({
name: 'modal',
data: {
// initial props, available as `data` in mod()
},
mod: ({ data, $ }) => {
// Lifecycle logic, store subscriptions, DOM helpers, etc.
// For example:
let root: HTMLDivElement | null = null;
function open(frag: DocumentFragment) {
root = $.create('div', { children: [frag] });
document.body.appendChild(root);
}
return {
open: async ({ frag }: { frag: string }) => {
if (root) root.remove();
const res = await $.fetch<DocumentFragment>(frag);
if (res.ok && res.content) open(res.content);
},
close: () => { root?.remove(); root = null; },
};
}
});
}
And register it like so:
// script.ts
import {createModule, createScript} from '@trifrost/core';
import {type Env} from './types.ts';
import {css} from './css';
import {Modal} from './modules/Modal';
export const {Module} = createModule({css});
const config = {
atomic: true,
css,
modules: {
modal: Modal,
},
} as const;
export const {Script, script} = createScript<typeof config, Env>(config);
Now any <Script>
can access it using $.modal.open(...)
, $.modal.close(...)
.
π§ Modules are just-in-time delivered, singleton-scoped, and typed via your
Module(...)
config, they are only included when referenced in a<Script>
block.
Just like <Script>
, data={...}
is fully typed and passed into the body, and Atomic features work the same way.
Whatβs provided?
Inside a Module
, your function receives:
mod
: Relay object with$publish
,$subscribe
, etc.data
: The initial props passed viadata={...}
(reactive if Atomic is enabled)$
: All scoped Atomic utilities (like in<Script>
)
Notes
- Modules must define a
name
(e.g.name:"modal"
). - Modules are deemed singletons. You cannot render the same module multiple times on the same page, only the first will execute.
- Modules are ideal for global listeners, bridge layers, effects, modals, etc.
Want more reactive power? Check out the Atomic Runtime and unlock full pubsub, store, and signal sync.
βοΈ External Scripts
If you pass a src
prop, the script is rendered as a normal <script>
tag:
<Script src="https://cdn.example.com/foo.js" defer />
// Renders as:
<script nonce="abc123" src="https://cdn.example.com/foo.js" defer></script>
All standard script attributes are supported (src, type, async, defer, ...), and the tag will be rendered directly into the HTML.
π§ Inline Scripts
You can also use <Script>
to bind behaviors directly to elements during hydration.
<button type="button">
Click Me
<Script>{({el}) => {
el.addEventListener('click', () => {
alert('Clicked!');
});
}}</Script>
</button>
This script is serialized at render time, registered with a unique hash, and re-attached to matching DOM nodes on the client via data-tfhf="..."
.
How: Hydration Model
TriFrost scripts run only on the client, but are defined alongside your markup on the server.
When JSX is rendered on the server, your script function is:
- Captured as a string
- Hashed and registered
- Injected into a hydration payload
On the client, this payload:
- Locates the target node (via
data-tfhf
) - Re-attaches the function and invokes it with
{el, data, $}
π§ Your function does not run during SSR. It is serialized as code, not executed.
This means:
- You can write
el.addEventListener(...)
as if you were in a<script>
tag - You cannot access
ctx
,request
, or anything server-bound inside<Script>
data
is your bridge from SSR to client
What's passed to Script?
Each inline script receives:
el: HTMLElement
The DOM element the script is bound to.
<div>
<Script>{({el}) => { /* el here is the div */
...
}}</Script>
</div>
data: object
The data={...}
you passed to the script. Writable. Not reactive by default (unless Atomic is enabled).
𧬠Data is fully typed, TypeScript will infer the shape of your data
object and reflect it in the script body.
Example:
<div>
<Script data={{count: 42}}>{({el, data}) => {
/* data here is {count: 42} and auto-typed as {count:number} */
}}</Script>
</div>
β This gives you end-to-end type safety from SSR β client, without manual casts or schema validation.
$: Atomic Utils
A set of scoped, DOM-safe utilities:
$.on
,$.once
,$.fire
for events$.query
,$.clear
for DOM traversal$.storeSet
,$.storeGet
for global store state$.uid
,$.eq
,$.sleep
,$.fetch
, etc.
<button type="button">
Click Me
<Script>{({el, $}) => {
$.on(el, 'click', () => alert('Clicked!'));
}}</Script>
</button>
See JSX Atomic Runtime for the full list.
β‘ Instant Execution Scripts
When using <Script>
with a function that takes no arguments, TriFrost optimizes it by inlining the logic directly into the HTML:
<Script>{() => {
console.log('Inline script ran immediately');
}}</Script>
This is:
- β Instant: no need to wait for the script engine
- β CSP-safe: nonce is automatically applied
- β Great for meta-level logic, e.g. setting theme or firing analytics
π‘ Think of this as a safer, scoped
<script>
tag, but written inline with full TypeScript support.
Under the hood, this produces:
<script nonce="abc123">(function(){console.log("Inline script ran immediately")})();</script>
This behavior is only enabled for scripts with no arguments:
<Script>{() => { ... }}</Script> // β
inlined
<Script>{({el}) => { ... }}</Script> // β not inlined, handled via hydration
This means you get instant execution only when no DOM binding is needed, perfect for boot-time setup, cookie flags, or third-party hooks.
For example:
/* Theme detection */
<Script>
{() => {
const saved = localStorage.getItem('theme');
const preferred = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', saved || preferred);
}}
</Script>
/* Locale detection */
<Script>
{() => {
const locale = navigator.language?.startsWith('fr') ? 'fr' : 'en';
document.documentElement.setAttribute('data-lang', locale);
}}
</Script>
Examples
Class Toggle
Toggle a class on click:
<Script>{({el}) => {
el.addEventListener('click', () => {
el.classList.toggle('active');
});
}}</Script>
Whats happening here:
- You can bind this to any node, including SVG or custom elements.
- No framework bindings or runtime needed.
Toggle with Data
Track and mutate open state in-place:
<div>
<span>Toggle Visibility</span>
<Script data={{open: false}}>{({el, data}) => {
el.addEventListener('click', () => {
data.open = !data.open;
el.setAttribute('aria-expanded', String(data.open));
});
}}</Script>
</div>
Whats happening here:
- Uses data.open to track local state.
- Updates aria-expanded attribute accordingly.
- Great for dropdowns, modals, etc.
Debounce (Atomic)
<Script>{({el, $}) => {
$.on(el, 'input', $.debounce(() => {
console.log('Typing stopped');
}, 300));
}}</Script>
Whats happening here:
- Uses the
$.debounce()
utility - Triggers only after user finishes typing
Reactive form (Atomic)
<form>
<fieldset>
<legend>Type</legend>
<label><input type="radio" name="type" value="all" /> All</label>
<label><input type="radio" name="type" value="blog" /> Blog</label>
<label><input type="radio" name="type" value="release" /> Release</label>
</fieldset>
<fieldset>
<legend>By Month</legend>
<label><input type="radio" name="month" value="all" /> All</label>
<label><input type="radio" name="month" value="2025-06" /> June 2025</label>
<label><input type="radio" name="month" value="2025-05" /> May 2025</label>
</fieldset>
<Script data={{filters: {type: 'all', month: 'all'}}}>
{({data, $}) => {
data.$bind('filters.type', 'input[name="type"]');
data.$bind('filters.month', 'input[name="month"]');
data.$watch('filters', async () => {
const res = await $.fetch<DocumentFragment>('/filter-news', {
method: 'POST',
body: data.filters,
});
if (res.ok && res.content) {
document.getElementById('news-list')?.replaceWith(res.content);
}
});
}}
</Script>
</form>
<div id="news-list">
<!-- Server-rendered list gets replaced here -->
</div>
Whats happening here:
data
: Holds the form's reactive state (filters.type
,filters.month
)data.$bind
: Connectsdata.filters
keys to DOM input valuesdata.$watch
: Triggers whenever the filters change$.fetch(...)
: Makes a POST request with current filters (the endpoint returns HTML)res.content
: Replaces the news list with the updated HTML fragment
This pattern is great for:
- News/blog filtering
- Product category filters
- Interactive search UIs
- Pagination triggers
And is exactly how the news section filter on this website works π€
Want another cool example? Check out this Synth Background component (which is what you see if you scroll all the way down on this page on a desktop browser).
π‘ Security & CSP
TriFrost scripts:
- Respect
nonce
values from the context - Scripts are safely serialized without
eval
- Code and data payloads are sandboxed in an IIFE to prevent scope leakage.
No runtime globals. No unsafe scopes.
Best Practices
- β
Define script once with
createScript()
- β
Define global logic with
Module(...)
- β
Register modules in
createScript({modules})
to enable typed, lazy-loaded access inside any<Script>
- β Co-locate behavior with elements
- β Donβt render modules manually or wrap them in JSX β define them once and reuse via
$
TLDR
- Use
<Script>
to hydrate parent-node behavior - Use
Module(...)
to define global logic (modals, notifications, audio, etc.) - Register modules with
createScript({modules})
for typed access via$.<name>
- Supports inline or external
src
-based scripts - Automatically handles CSP nonces
- Dedupes scripts and data at render time
- Co-locates behavior with components
- Requires
createScript()
factory for proper typing (eg: environment, events, store, ...) - π« Donβt call
createScript()
multiple times, define it inscript.css
and pass to App.
Next Steps
To become a true TriFrost-Samurai:
- Learn about JSX Atomic Runtime for reactivity, stores, global pubsub and more
- Need a refresher on JSX Basics?
- Take a technical dive into JSX Fragments?
- Or explore styling with createCss