React / Next.js
Initialize once at the root in a useEffect:
tsx
// App.tsx
import { useEffect } from 'react'
import Vivace from 'vivace-css'
import 'vivace-css/vivace.css'
export default function App() {
useEffect(() => {
Vivace.init()
return () => Vivace.destroy()
}, [])
return <Page />
}init() is idempotent and destroy() is the cleanup, so React 18/19
StrictMode's double-invoke is harmless.
Annotate JSX with the same attributes — data-* passes straight through:
tsx
<h1 data-viv="@fd @sl-y_ease-out-expo">Hello</h1>
<ul data-viv="@fd_child-ascend" data-viv-on="appearing">
{items.map((item) => (
<li key={item.id}>{item.label}</li>
))}
</ul>Next.js (App Router)
Effects only run on the client, so wrap the init in a small client component and render it once in the root layout:
tsx
// app/vivace-provider.tsx
'use client'
import { useEffect } from 'react'
import Vivace from 'vivace-css'
export function VivaceProvider() {
useEffect(() => {
Vivace.init()
return () => Vivace.destroy()
}, [])
return null
}
// app/layout.tsx
import 'vivace-css/vivace.css'
import { VivaceProvider } from './vivace-provider'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<VivaceProvider />
{children}
</body>
</html>
)
}Re-renders & route changes
React replacing DOM nodes is exactly what the MutationObserver watches for — newly mounted elements register themselves, and unmounted ones release automatically (the registry holds them weakly). Conditional rendering, suspense boundaries and client-side navigation all just work.
Programmatic control
tsx
import { useRef } from 'react'
import Vivace from 'vivace-css'
function Card() {
const ref = useRef<HTMLDivElement>(null)
return (
<>
<div ref={ref} data-viv="@pop">…</div>
<button onClick={() => ref.current && Vivace.trigger(ref.current)}>
replay
</button>
</>
)
}