Choreo is a minimal, library-agnostic, component choreography layer for Astro projects, focused on motion and interactive components.
- Automatic DOM detection via
data-componentattributes - Dependency-ordered initialization using Kahn's topological sort (BFS)
- Async readiness signaling — Component signal their "ready" state
- Global event coordination — Coordinates commonly tracked events: resize, prefers-reduced-motion, view transitions, unload
- Multiple instances — every matching element gets its own isolated instance
- Single-file Astro component editing — designed for single-file components in
.astrowith<script>tags - Resilient error handling — components fail without crashing the system
- Zero dependencies, no build step — raw TypeScript source that your own project's Vite compiles
npm install @viget/choreoIntended for use in any Vite-based site; Astro's <ClientRouter /> view transitions are supported out of the box, with a window load fallback for plain MPA sites.
---
// src/components/Accordion.astro
---
<!-- data-component marks component root -->
<div data-component="accordion">
<!-- data-ref marks a child element for easy retrieval later -->
<button data-ref="trigger">Open</button>
<div data-ref="panel">...</div>
</div>
<script>
import { defineComponent } from '@viget/choreo';
defineComponent('accordion', {
init({ element, ref, ac }) {
const trigger = ref<HTMLButtonElement>('trigger');
const panel = ref<HTMLElement>('panel');
trigger?.addEventListener('click', () => {
panel?.classList.toggle('open');
}, { signal: ac.signal }); // removed automatically on destroy
},
});
</script>This shows a simple example of setting up a component and invoking Choreo.
- Each component is identified with a
data-componentand a unique name - (Optional) Child elements that will be needed later are marked with
data-refand a unique name, accessed via theref()helper later. - The co-located
<script>tag imports Choreo and uses the wrappingdefineComponent()function and the name of the component to attach to (ex.accordion). - Runtime logic lives in the
init()function, which receives commonly needed info from Choreo (see Component Context).
Script duduping is currently handled by default in Astro. The script runs once, and isolated instances are created for every matching component during the scan.
The main wrapper function. Registers a component definition with the shared Choreo singleton.
The component definition expects two shapes:
deps(Default of[]) An array of other components that are dependencies. Choreo will ensure everything in this array initialize first. Dependencies that are absent from the DOM will silently fail (no blocking).init()The runtime of the component, that receives commonly used information from Choreo (see Component Context)
import { defineComponent } from '@viget/choreo';
defineComponent('hero', {
deps: ['nav'], // optional — wait for 'nav' instances to finish init
init({ find, findAll, ref, ac, system, viewport, prefersReducedMotion, log }) {
const title = ref<HTMLElement>('title');
const buttons = findAll<HTMLButtonElement>('.btn');
log('init', { viewport, prefersReducedMotion });
// system.on and DOM listeners share the same AbortController
system.on('resize', ({ viewport }) => {
log('resize', viewport);
}, { signal: ac.signal });
buttons.forEach(btn =>
btn.addEventListener('click', handleClick, { signal: ac.signal })
);
function handleClick() { /* ... */ }
// ac.abort() is called automatically on destroy — return a function only
// for non-listener cleanup (e.g. resetting animation state)
}
});Each init() call receives:
| Property | Type | Description |
|---|---|---|
element |
HTMLElement |
The DOM element bound to this instance (identified by data-component) |
viewport |
Readonly<Viewport> |
Live reference — always current { width, height } |
prefersReducedMotion |
boolean |
Value at time of init |
system |
Choreo |
System reference for on/off event subscription |
ac |
AbortController |
System-managed controller — aborted on destroy |
find |
<T extends Element>(selector: string) => T | null |
querySelector scoped to element |
findAll |
<T extends Element>(selector: string) => T[] |
querySelectorAll scoped to element, returns an array |
ref |
<T extends Element>(name: string) => T | null |
Finds [data-ref="name"] within element |
log |
(msg: string, ...args: unknown[]) => void |
Dev-only logger prefixed with the component name |
Opt-in subscription to global system events:
| Event | Callback payload |
|---|---|
'resize' |
{ viewport: Viewport } (debounced 250ms — fires once resizing settles) |
'motionchange' |
{ prefersReducedMotion: boolean } |
When an AbortSignal is passed, the listener is removed automatically on abort — one AbortController can clean up DOM and system listeners together. An already-aborted signal means the listener is never added, matching DOM addEventListener semantics. A subscriber that throws is logged and skipped; it never blocks other subscribers.
system.on('resize', ({ viewport }) => { /* ... */ }, { signal: ac.signal });On destroy (page navigation, unload):
- The instance's
ac.abort()fires — removes all listeners registered withac.signal - The cleanup function returned from
init()runs (if any)
Components that route all listeners through ac.signal need no return value. A cleanup function that throws is logged and skipped — it never blocks other instances' cleanup.
Components declare deps: string[]. Initialization runs in waves:
Wave 0: ['footer', 'nav'] // no deps
Wave 1: ['hero'] // deps: ['nav']
Wave 2: ['carousel'] // deps: ['hero']
Waves run sequentially; instances within a wave initialize concurrently. A failed init() logs a warning but still unblocks its dependents. Circular dependencies throw: "Circular dependency detected among: hero, carousel".
| Event | Behavior |
|---|---|
window resize |
Debounced 250ms → updates viewport, emits 'resize' |
prefers-reduced-motion change |
Updates state, emits 'motionchange' |
astro:page-load |
Destroys all instances, re-scans the DOM (a navigation that lands mid-scan queues a re-scan rather than being dropped) |
window load |
Fallback initial scan for sites without <ClientRouter />; if the module loads after the load event (e.g. via dynamic import), the scan is scheduled immediately |
window pagehide |
Destroys all instances on real unloads. When the page enters the back/forward cache (persisted: true), instances are left intact so the restored page keeps working |
In development the singleton is exposed as window.__choreo (stripped from production builds):
__choreo.instances // active instances
__choreo.viewport // current viewport
__choreo.scanning // scan in progress?
__choreo.scan() // scan for newly added [data-component] elementsElements that already have a live instance are skipped by scan(), so calling it manually only picks up elements added to the DOM since the last scan — it never double-initializes.
The log context helper prefixes output with the component name, and is a no-op in production:
[hero] init { width: 1440, height: 900 }
npm install
npm test # vitest run
npm run typecheck # tsc --noEmitTests live alongside the source in src/*.test.ts and are excluded from the published package.
AI (Claude Code) was used as an assistant to accelerate feature development, streamline QA, and to help catch errors before they ship. Read more about how we use AI at Viget.
Lightweight provisions have been made for code assistants for future feature development:
| File | Purpose |
|---|---|
CLAUDE.md |
Automatically read by Claude, points to context and sets boundaries |
CONTEXT.md |
Living reference for AI agents to supply commands, architecture, and reasoning for future sessions |
