Appearance
For vanilla-JS + plugin shops
Most production teams aren't on a pure framework stack. They have:
- A React admin panel from 2021
- A Vue marketing site from 2023
- A jQuery + plugins legacy app no one wants to touch
- And one new Astro project the new hire keeps pushing for
And every one of those apps glues the same vanilla-JS engines into its framework: flatpickr for dates, SortableJS for drag-and-drop, Leaflet for maps, TipTap for rich text, Chart.js for graphs, Uppy for uploads, FullCalendar for scheduling.
Each app's glue is a different shape. Each upgrade of the engine breaks the glue in a different way. Each new framework you adopt means writing the glue again.
Rozie wraps each engine once; the same wrapper drops into every one of your apps. Better still: for fifteen of the most common engines, the wrapper is already written, tested, and published.
Start with the shipped wrappers
The @rozie-ui catalog ships 15 engine-backed families, each a battle-tested vanilla-JS engine wrapped once and published as six per-framework packages: DataTable (TanStack table-core) · SortableList (SortableJS) · Flatpickr · FullCalendar · CodeMirror · Chart.js · TipTap · Lexical · MapLibre · Cropper · Waveform (wavesurfer.js) · PdfViewer (pdf.js) · FlowCanvas (Rete) · Carousel (Embla) · Popover & Tooltip
Install only your framework's package (@rozie-ui/sortable-list-react, -vue, -svelte, -angular, -solid, -lit); there is no Rozie toolchain in your dependency tree, just the component and a few KB of tree-shaken runtime helpers. If the engine you fight is on that list, you may not need to write a wrapper at all. Browse all components →
Writing your own wrapper is the second path, for engines Rozie hasn't wrapped yet. The rest of this page is that pattern.
The engine-wrapper pattern
The leverage criterion: the engine itself must be framework-agnostic vanilla JS. Rozie wraps the wrapper, not the engine, so the engine stays exactly where it is.
Examples that fit:
- flatpickr — date / range / time picker
- SortableJS — drag-and-drop reordering
- Leaflet / Mapbox GL / MapLibre GL — maps
- TipTap / Lexical / CodeMirror 6 / Monaco — text editors
- Chart.js / ApexCharts / D3 — charting
- Uppy — file upload
- FullCalendar — scheduling
- Pikaday — lightweight date picker
- glide.js / swiper — carousels
Examples that DON'T fit (the value IS the framework-native implementation, not the engine):
react-day-picker(no separable vanilla-JS engine)vue-cal(Vue-native logic, not a wrapper)- Headless UI / Radix UI (the value is the React-specific accessibility semantics)
If the engine has a vanilla-JS public API you'd call from a plain <script> tag, it fits the Rozie pattern.
Side by side — SortableJS
What you maintain today (one wrapper per framework)
You either install someone else's wrapper (react-sortablejs, vuedraggable, ngx-sortablejs, svelte-sortablejs), each with its own API, bug surface, maintainer, and upgrade cycle, or you hand-roll glue inside each app.
Either way, you're maintaining the cross-framework workaround for SortableJS's DOM-mutation-vs-framework-reconciler fight independently in each codebase.
What you maintain with Rozie (one wrapper, period)
rozie
<rozie name="SortableList">
<props>
{
items: { type: Array, default: () => [], model: true },
itemKey: { type: String, default: null },
handle: { type: String, default: null },
animation: { type: Number, default: 150 },
}
</props>
<script>
import SortableJS from 'sortablejs'
let instance = null
$onMount(() => {
instance = new SortableJS($el, {
animation: $props.animation,
handle: $props.handle,
onEnd: (e) => $emit('end', e),
onUpdate: (e) => {
// Restore pre-drag DOM so the framework reconciler sees a clean
// model change against unchanged DOM. Worked out once, works
// everywhere.
e.item.remove()
$el.insertBefore(e.item, $el.children[e.oldIndex] ?? null)
const next = [...$props.items]
const [moved] = next.splice(e.oldIndex, 1)
next.splice(e.newIndex, 0, moved)
$model.items = next
// Lit's lit-html `repeat` caches part identity by sentinel-comment
// node. Engine DOM mutation desyncs it. No-op on every other target.
$reconcileAfterDomMutation()
$emit('change', e)
},
})
return () => instance?.destroy()
})
$watch(() => $props.handle, (v) => instance?.option('handle', v))
</script>
<template>
<div class="rozie-sortable-list" r-external>
<div r-for="item, index in $props.items" :key="item.id ?? index">
<slot :item="item" :index="index" />
</div>
</div>
</template>
</rozie>That is the complete wrapper. Use it from every app you maintain:
tsx
// In your React admin panel
import { SortableList } from '@your-org/components';
<SortableList items={todos} onItemsChange={setTodos} itemKey="id">
{({ item }) => <div className="row">{item.text}</div>}
</SortableList>vue
<!-- In your Vue marketing site -->
<SortableList v-model:items="todos" item-key="id">
<template #default="{ item }">
<div class="row">{{ item.text }}</div>
</template>
</SortableList>svelte
<!-- In your SvelteKit dashboard -->
<SortableList bind:items={todos} itemKey="id">
{#snippet children({ item })}
<div class="row">{item.text}</div>
{/snippet}
</SortableList>ts
// In your Angular admin
@Component({
template: `
<rz-sortable-list [(items)]="todos" itemKey="id">
<ng-template #default let-ctx>
<div class="row">{{ ctx.item.text }}</div>
</ng-template>
</rz-sortable-list>
`,
})html
<!-- In your Astro / plain HTML page (Lit target) -->
<rz-sortable-list .items=${todos} item-key="id">
${todos.map(item => html`<div class="row">${item.text}</div>`)}
</rz-sortable-list>Five consumer-side authoring shapes, one wrapper.
The compiler invariants that make this work
A set of engine-wrapper-specific compiler features exists because vanilla-JS engines are a core Rozie use case. The engine-wrapper toolkit documents each in full; the load-bearing ones:
$ellowers to the right per-target host element, so engine constructors that take a DOM node always get the right one.$onMountreturning a cleanup function is the right shape on every target. NouseEffectceremony, noDestroyRefboilerplate.$watch(() => $props.x, (v) => …)lowers to each target's idiomatic reactive primitive. The callback fires when the watched expression changes and the new value is bound correctly on all six targets.$reconcileAfterDomMutation(), paired with ther-externaltemplate marker, is the escape hatch when the engine mutates DOM under the framework's feet: a no-op on five targets, active on Lit where lit-html'srepeatcache needs the rebuild.$classSelector('grip')is a typo-checked convenience: authored class names render literally on every target (React included — no class hashing), so engines that take ahandle: '.grip'option Just Work everywhere;$classSelectoradditionally verifies the class exists in your<style>at compile time.- Round-trip guards on two-way bound engine state. When the engine fires its change event you write
$model.x = newValue; the wrapper's own$watchthen sees the change and would push it back. Common pattern: guard withif (newValue !== getCurrentEngineValue()) updateEngine(newValue)inside the$watch. Documented in the SortableList example. - TypeScript
as/satisfiesannotations in<data>block initializers —selected: null as Item | nullkeeps the field discriminated instead of degrading tonull/any.
Where the shipped wrappers came from
Each @rozie-ui engine family started life as a stress test of the compiler's engine-wrapper substrate: SortableList exercises a two-way array prop plus engine DOM mutation and the $reconcileAfterDomMutation() escape hatch; Flatpickr exercises scalar two-way binding; Chart.js exercises deeply-nested object reactivity with interval-driven $data mutation; TipTap and Lexical exercise contenteditable DOM ownership; FullCalendar exercises structured-payload multi-emit and portal-slot fills; Uppy exercises a multi-event emit chain with streaming progress.
Those wrappers graduated into the shipped @rozie-ui families listed above; only LeafletMap.rozie and Uppy.rozie remain as compact reference sources under the repo's examples/ directory. For learning the pattern, the annotated walkthroughs at SortableList, Flatpickr, and LineChart (Chart.js) are the best reading order.
Cross-framework leverage
For a team running a React app + a Vue app + an Angular admin + a static HTML site (Lit), with four engines in play (Sortable + Flatpickr + Leaflet + Chart.js): today that is sixteen distinct glue surfaces, one per app-engine pair. With Rozie it is four wrappers, each imported into each app the same way any native component is.
The first wrapper takes maybe a day. The second takes an hour, because you already know the pattern. The reference examples in this repo are the template.
How to start
Step 1: Check the shipped catalog
Scan the component catalog first. If your engine is already wrapped, install your framework's package and stop here.
Step 2: Pick the engine that hurts the most
Of the engines Rozie hasn't wrapped: the one you've fought across the most apps. The one that breaks on every framework upgrade. The one whose existing wrapper library you don't trust.
Step 3: Read the closest reference
SortableList is the canonical demo — two-way bound array, DOM mutation, scoped slot for per-row rendering. Flatpickr is the simpler scalar-two-way pattern.
Step 4: Wrap your engine
Copy one of the reference files. Replace SortableJS with your engine. Wire $onMount / $onUnmount for instantiation and teardown. Use $watch to push prop changes into the engine. Use $emit for engine events. Use r-model: props for state the engine wants to own.
Step 5: Drop it into one app, then another
The Adopt incrementally guide covers the per-stack install. Start with whichever framework is least intimidating; the others come for free once it works in the first one.
Next steps
- Browse components — the 15 shipped engine-backed
@rozie-uifamilies, installable per framework today. - SortableList example — the canonical engine wrapper, full source + per-target output.
- Flatpickr example — simpler scalar two-way.
- Engine-wrapper toolkit —
$classSelector,r-external+$reconcileAfterDomMutation,$slotted,r-portal,$restoreFocus,$snapshot,$clone. - Creature comforts — the full matrix of cross-framework normalizations.
- Adopt incrementally — drop a wrapper into your existing apps.