A host-agnostic, Svelte 5 video timeline editor — a CapCut/Premiere-style timeline with clips, tracks, trimming, ripple/roll/slip edits, grouping, linked audio, markers, in/out range, undo/redo, and a real-time DOM preview. No framework lock-in: all external concerns (asset URLs, thumbnails, export, permissions, persistence, notifications, confirmation, i18n) are injected by the host.
- Built-in clip transitions — per-clip enter/exit animations (fade, slide, scale, zoom, bounce, pop, spin, blur, wipe, flip) with duration + easing, scrubbed live by the playhead.
- Responsive & mobile-ready — the bin and clip inspector collapse into bottom sheets, the toolbar folds into an overflow menu, and clip options are reachable by touch.
- The library owns no storage — you persist the project (and pane height) however you like.
Every clip's
animationrides along in the project JSON, so the host can render it any way it likes on export. - Tailwind v4 theming — ships a simple light/dark theme you can override via CSS variables.
⚠️ Browser-only. This component renders in the browser (DOM,<video>/<audio>, canvas,requestAnimationFrame). It is safe to import on the server, but render it client-side only (see SvelteKit (SSR)).
- Demo: https://svelte-video-editor.ariefsn.dev
- Repo: https://github.com/ariefsn/svelte-video-editor
- Install
- Theme
- Simple mode — props & callbacks only
- Advanced mode — snippets, i18n, host-owned state
- Opening media — file picker, drag-and-drop,
binImport - SvelteKit (SSR)
- Host contract
- Clip transitions — built-in enter/exit animations
- Section overrides
- Custom preview renderer — Remotion, canvas, anything
- Localization
- License
bun add @ariefsn/svelte-video-editor
# or: npm i @ariefsn/svelte-video-editor
# or: pnpm add @ariefsn/svelte-video-editorRequires svelte@^5 and Tailwind v4 in your app.
Import the shipped theme once (it defines the --ts-* CSS variables + light/dark palette):
// app.css or your root layout
import '@ariefsn/svelte-video-editor/app.css';Dark mode follows a .dark class on an ancestor (e.g. <html class="dark">). Override any token
to restyle:
:root {
--ts-primary: hsl(220 90% 56%); /* your brand */
}The minimal "drop it in and go" setup — props and callbacks only, no snippets. You supply a
project, persist it in onChange, and resolve media. That's it.
<script lang="ts">
import { browser } from '$app/environment';
import {
TimelineEditor,
createEmptyProject,
migrateProject,
type NotifyKind,
type ResolvedAsset,
type TimelineProject
} from '@ariefsn/svelte-video-editor';
import '@ariefsn/svelte-video-editor/app.css';
// The host owns the project and its persistence. The library stores nothing.
let project = $state<TimelineProject | null>(null);
$effect(() => {
if (!browser || project) return;
const saved = localStorage.getItem('my-project');
project = saved ? migrateProject(JSON.parse(saved)) : createEmptyProject('My first video');
});
function handleChange(p: TimelineProject) {
project = p;
localStorage.setItem('my-project', JSON.stringify(p)); // host-owned persistence
}
// Resolve a clip's media URL (+ metadata). Here URLs resolve to themselves.
async function resolveAsset(assetId: string): Promise<ResolvedAsset> {
return { url: assetId, hasAudio: true };
}
// Return a thumbnail image URL for `frame` (at a fixed 30fps reference).
async function generateThumbnail(assetId: string, frame: number): Promise<string> {
return assetId; // e.g. an <img> poster; real apps seek a <video> + canvas
}
function handleExport(p: TimelineProject) {
console.log('export', p); // you own rendering
}
function handleNotify(message: string, kind: NotifyKind) {
console.log(kind, message); // wire to your toast system if you like
}
</script>
{#if browser && project}
{#key project.id}
<TimelineEditor
{project}
onChange={handleChange}
{resolveAsset}
{generateThumbnail}
onExport={handleExport}
can={() => true}
onNotify={handleNotify}
/>
{/key}
{/if}onBack is omitted here, so no back button renders.
Use snippets to customize sections, supply your own confirm dialog, switch languages at runtime, gate features, and own the pane height — everything injected by the host.
<script lang="ts">
import { browser } from '$app/environment';
import {
TimelineEditor,
InspectorPanel,
createEmptyProject,
uid,
type BinItem,
type ConfirmOptions,
type EditorAction,
type MessagesOverride,
type ResolvedAsset,
type SectionCtx,
type TimelineProject
} from '@ariefsn/svelte-video-editor';
import '@ariefsn/svelte-video-editor/app.css';
let project = $state<TimelineProject | null>(null);
let timelineHeight = $state<number | undefined>(undefined);
let lang = $state<'en' | 'id'>('en');
let proTier = $state(true);
$effect(() => {
if (!browser || project) return;
project = createEmptyProject('Advanced demo');
timelineHeight = Number(localStorage.getItem('paneH')) || undefined;
});
async function resolveAsset(assetId: string): Promise<ResolvedAsset> {
return { url: assetId, hasAudio: true };
}
async function generateThumbnail(assetId: string): Promise<string> {
return assetId;
}
function handleChange(p: TimelineProject) {
project = p;
}
function handleExport(p: TimelineProject) {
console.log('export', p);
}
function handleHeight(h: number) {
timelineHeight = h;
localStorage.setItem('paneH', String(h)); // host-owned persistence
}
function canDo(action: EditorAction): boolean {
return action === 'export' ? proTier : true;
}
// Localization is host-owned: swap the whole messages map to switch language.
const messagesId: MessagesOverride = { export: 'Ekspor', split: 'Pisah', undo: 'Urungkan' };
const messages = $derived<MessagesOverride | undefined>(lang === 'id' ? messagesId : undefined);
// Use your OWN confirm dialog instead of the built-in one (optional).
async function confirm(opts: ConfirmOptions): Promise<boolean> {
return window.confirm(`${opts.title}\n\n${opts.message ?? ''}`);
}
let pasteUrl = $state('');
function addByUrl(addItems: (items: BinItem[]) => void) {
if (!pasteUrl.trim()) return;
addItems([{ id: uid(), url: pasteUrl, name: 'media', mediaType: 'video', duration: null }]);
pasteUrl = '';
}
</script>
{#if browser && project}
{#key project.id}
<TimelineEditor
{project}
onChange={handleChange}
{resolveAsset}
{generateThumbnail}
onExport={handleExport}
can={canDo}
{messages}
{confirm}
{timelineHeight}
onTimelineHeightChange={handleHeight}
onBack={() => history.back()}
>
<!-- Host-owned import UI inside the asset bin -->
{#snippet binImport({ addItems })}
<form
onsubmit={(e) => {
e.preventDefault();
addByUrl(addItems);
}}
>
<input bind:value={pasteUrl} placeholder="Paste media URL…" />
<button>Add</button>
</form>
{/snippet}
<!-- WRAP a default section: render the built-in + add your own UI -->
{#snippet inspector(ctx: SectionCtx)}
{#if ctx.editor.activeClip}
<InspectorPanel onRequestDelete={ctx.onRequestDelete} />
{/if}
<div class="p-3 text-xs">Active clip: {ctx.editor.activeClip?.name ?? '—'}</div>
{/snippet}
<!-- FULLY REPLACE a section -->
{#snippet shortcutsFooter()}
<div class="border-t px-3 py-1 text-xs">My custom footer</div>
{/snippet}
</TimelineEditor>
{/key}
{/if}Switch lang between 'en' and 'id' at runtime and the whole UI re-labels reactively — no
locale prop, the messages map is the single source of translation.
A runnable version of both modes lives in this repo under
src/routes/(/= simple,/advanced= advanced). Runbun run dev.
The editor ships no import UI on purpose. It never touches files, never fetches and never
builds storage URLs, so you decide where media comes from — an upload endpoint, an existing
asset library, a stock picker, or the local disk. That UI goes in the binImport snippet, which
receives addItems(items: BinItem[]) and renders inside the asset bin.
Until you supply it, the bin stays empty and there is no way for a user to add media — that is the contract, not a bug.
<script lang="ts">
import { uid, type BinItem } from '@ariefsn/svelte-video-editor';
let fileInput: HTMLInputElement | null = $state(null);
let dragging = $state(false);
// Keep blob URLs OUT of the saved project: register each file under a
// stable assetId, and let resolveAsset hand back the live URL.
const localAssets = new Map<string, string>();
async function handleFiles(files: FileList | null, addItems: (i: BinItem[]) => void) {
if (!files?.length) return;
const items: BinItem[] = [];
for (const file of files) {
const mediaType = file.type.startsWith('video/')
? 'video'
: file.type.startsWith('audio/')
? 'audio'
: 'image';
const url = URL.createObjectURL(file);
const assetId = `local:${uid()}`;
localAssets.set(assetId, url);
items.push({ id: uid(), assetId, url, name: file.name, mediaType, duration: null });
}
addItems(items);
}
</script>
{#snippet binImport({ addItems })}
<!-- preventDefault on dragover, or the browser navigates to the dropped file -->
<div
ondragover={(e) => {
e.preventDefault();
dragging = true;
}}
ondragleave={() => (dragging = false)}
ondrop={(e) => {
e.preventDefault();
dragging = false;
handleFiles(e.dataTransfer?.files ?? null, addItems);
}}
class={dragging ? 'ring-2' : ''}
>
<input
bind:this={fileInput}
type="file"
multiple
accept="video/*,audio/*,image/*"
hidden
onchange={(e) => {
handleFiles(e.currentTarget.files, addItems);
e.currentTarget.value = ''; // so re-picking the same file re-fires
}}
/>
<button onclick={() => fileInput?.click()}>Open media</button>
<p>or drop files here</p>
</div>
{/snippet}Pair it with a resolveAsset that knows about those ids:
async function resolveAsset(assetId: string) {
return { url: localAssets.get(assetId) ?? assetId, hasAudio: true };
}
⚠️ blob:URLs must never be persisted. They die when the tab closes, so a project saved with one comes back pointing at media that can never load. Store theassetIdand resolve a fresh URL each session — stripblob:values in youronChangehandler before writing them anywhere. For media that must survive a reload, upload the file and use the uploaded URL.
Set duration (source length in seconds) when you know it, and clips get the right length on
the timeline; leave it null and the editor falls back to a default. You can probe it from a
detached <video>/<audio> on loadedmetadata.
See src/routes/advanced/OpenMediaControl.svelte
for the complete version used by the /advanced demo, including duration probing and
session-expiry handling.
SvelteKit is SSR by default and the editor needs the DOM — render it under a browser guard:
<script lang="ts">
import { browser } from '$app/environment';
import { TimelineEditor } from '@ariefsn/svelte-video-editor';
</script>
{#if browser}
<TimelineEditor … />
{/if}(Or set export const ssr = false on the route.)
| Prop | Type | Required | Notes |
|---|---|---|---|
project |
TimelineProject |
✅ | Auto-migrated from older shapes. Remount via {#key project.id} to switch. |
onChange |
(project) => void |
✅ | Debounced (changeDebounceMs, default 800ms; 0 = immediate). You persist it. |
resolveAsset |
(assetId) => Promise<ResolvedAsset> |
✅ | Resolve media URL + metadata. See Opening media. |
generateThumbnail |
(assetId, frame) => Promise<string> |
✅ | frame at a fixed 30fps reference. |
onExport |
(project, range?) => void |
✅ | You own rendering/export. |
can |
(action) => boolean |
✅ | Permission gate ('export', 'magnetic-main-track'). |
onBack |
() => void |
❌ | Back button renders only when provided. |
onNotify |
(message, kind) => void |
❌ | Transient feedback sink; no-ops if absent. |
messages |
Partial<Messages> |
❌ | Override/translate any label (the single i18n source). |
confirm |
(opts) => Promise<boolean> |
❌ | Use your own confirm dialog instead of the built-in. |
timelineHeight / onTimelineHeightChange |
number / (h) => void |
❌ | Host-owned timeline pane height. |
binWidth / onBinWidthChange |
number / (w) => void |
❌ | Host-owned bin column width (desktop; draggable separator). |
inspectorWidth / onInspectorWidthChange |
number / (w) => void |
❌ | Host-owned inspector column width (desktop; draggable separator). |
magneticMainTrack |
boolean |
❌ | CapCut-style packing on the first track. |
changeDebounceMs |
number |
❌ | Debounce for onChange. |
ProjectListView is also exported for a "pick/create a project" screen (projects, onOpen,
onCreate, onRename, onDelete, plus optional messages / confirm).
Every clip (video, image, text) can carry an optional enter and exit transition, edited from
the inspector's Animation section and previewed live (the playhead scrubs them). The data is a
plain field on the clip, so it round-trips through onChange / onExport for the host to render.
import {
type ClipAnimation,
type ClipTransition,
type AnimPreset, // 'fade' | 'slide-left'|'slide-right'|'slide-up'|'slide-down'
// | 'scale' | 'zoom' | 'bounce' | 'pop' | 'spin' | 'blur' | 'wipe' | 'flip'
type Easing, // 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out'
ANIM_PRESETS,
EASINGS,
defaultTransition,
clipAnimStyle // pure: (clip, playheadSec, fps) => { opacity, transform, filter, clipPath }
} from '@ariefsn/svelte-video-editor';
// On each clip: clip.animation?: { in?: ClipTransition; out?: ClipTransition }
// ClipTransition = { preset: AnimPreset; durationF: number; easing: Easing }To render transitions yourself (e.g. a custom/Remotion compositor), call clipAnimStyle(clip, playheadSec, fps) for the exact per-frame visual the editor preview uses — preview and export then
match frame-for-frame. See docs/clip-animations-research.md.
Replace any section via a snippet receiving SectionCtx ({ editor, host, onBack?, onRequestDelete }):
toolbar, assetBin, preview, transport, inspector, tracks, shortcutsFooter, plus
binImport (host import UI, gets { addItems }). You can wrap a default by rendering the
exported section component (e.g. <TimelineToolbar {...ctx} />) and adding your own UI around it.
binImport is the one you almost certainly need: the library ships no media-import UI at all,
so without it the asset bin has no way to fill. See Opening media for a
ready-made file picker and drop zone.
Yes — you can replace the preview entirely (Remotion, a canvas player, a WebGL compositor,
anything). The built-in PreviewStage is just the default; pass a preview snippet and the
editor renders yours instead. There are two separate surfaces to consider:
| Surface | Override | What it is |
|---|---|---|
| Live preview (in-editor scrubbing) | preview snippet |
Real-time playback while editing. |
| Final render (export) | onExport(project, range?) |
You already own this — render however you like. |
The snippet receives the same SectionCtx as every section override:
<script lang="ts">
import { TimelineEditor, type SectionCtx } from '@ariefsn/svelte-video-editor';
</script>
<TimelineEditor {project} {onChange} {resolveAsset} {generateThumbnail} {onExport} {can}>
{#snippet preview(ctx)}
<!-- ctx.editor.project — full reactive timeline (tracks, clips, fps, aspectRatio) -->
<!-- ctx.editor.playhead — current position, in seconds -->
<!-- ctx.editor.playing — boolean play/pause state -->
<!-- ctx.host.resolveAsset — (assetId) => Promise<ResolvedAsset> for media URLs -->
<MyRenderer
project={ctx.editor.project}
playhead={ctx.editor.playhead}
playing={ctx.editor.playing}
resolveAsset={ctx.host.resolveAsset}
/>
{/snippet}
</TimelineEditor>All the data types your renderer needs (TimelineProject, MediaClip, TextClip,
TimelineTrack, frameToSec, secToFrame, isMediaClip, isTextClip, …) are exported from
the package.
Non-Svelte renderers (a Remotion/React composition, a canvas/WebGL player, a server-side
renderer, plain Node) must import from the /pure subpath instead. The main entry only
resolves under the svelte condition and pulls in .svelte components; /pure is
framework-free and resolves in any bundler.
/pure exposes the same composition rules the built-in preview uses, so any player can
reproduce it exactly instead of re-implementing them. Times are float seconds on the project
timeline (t = frame / fps):
| Question | Helper |
|---|---|
| How long is the project? | projectDurationF(project) |
What is on screen at t? |
visibleClipsAt(project, t) → { clip, zIndex }[], bottom → top |
All layers (for <Sequence>s) |
layeredClips(project) — z-ordered, hidden tracks skipped |
Is this clip active at t? |
isClipActive(clip, t, fps) — [start, end) |
| Where in the source media? | clipSourceSec(clip, t, fps) — honours trimInF |
| Output / stage pixel size | compositionSize(aspectRatio, shortSide?), fitStage(aspectRatio, w, h) |
| Background | backgroundCss(project.background) |
| Enter/exit animation | clipAnimStyle(clip, t, fps) |
| Media layer style | mediaClipCss(anim) — full-bleed object-fit: cover + animation |
| Text layer style | textClipCss(clip.style, stageHeight, anim) — % sizes → px |
| Heard at all? (mute/solo/…) | clipAudible(project, clip), trackAudible(project, track) |
Volume at t (with fades) |
clipGain(clip, t, fps) |
| Stored/older project | migrateProject(input) |
The style helpers return camelCase CSS objects — pass them straight to a React style prop,
Object.assign(el.style, css), or serialize with toCssText(css). A minimal DOM player:
import {
backgroundCss,
clipAnimStyle,
isMediaClip,
mediaClipCss,
textClipCss,
toCssText,
visibleClipsAt,
type TimelineProject
} from '@ariefsn/svelte-video-editor/pure';
function renderFrame(stage: HTMLElement, project: TimelineProject, t: number) {
stage.style.background = backgroundCss(project.background);
const html = visibleClipsAt(project, t).map(({ clip, zIndex }) => {
const anim = clipAnimStyle(clip, t, project.fps);
return isMediaClip(clip)
? `<img src="${clip.url}" style="z-index:${zIndex}; ${toCssText(mediaClipCss(anim))}">`
: `<div style="z-index:${zIndex}; ${toCssText(textClipCss(clip.style, stage.clientHeight, anim))}">${clip.text}</div>`;
});
stage.innerHTML = html.join('');
}Remotion works, but it's host-owned glue — the library gives you the seam and the data, not a drop-in Remotion player. There are two things to bridge:
- React ↔ Svelte. Remotion's
<Player>is a React component and this is Svelte 5, so you mount a small React island (createRoot) inside a Svelte wrapper. - Playback. The built-in
PlaybackEngineonly drives the native<video>/<audio>elements inPreviewStage— it does not drive Remotion. You sync the editor store and the Player'splayerRefto each other yourself, in both directions.
The store API you wire against (all reactive, exported types):
| Read from editor | Meaning | Call on editor (when user scrubs inside the Player) |
|---|---|---|
editor.playhead |
current time, float seconds | editor.seek(seconds) / editor.seekFrame(frame) |
editor.playing |
boolean | editor.play() / editor.pause() / editor.togglePlay() |
editor.fps |
project fps (int) | — |
editor.project |
full TimelineProject |
— |
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { createRoot, type Root } from 'react-dom/client';
import { createElement } from 'react';
import { Player, type PlayerRef } from '@remotion/player';
import { compositionSize, secToFrame, type SectionCtx } from '@ariefsn/svelte-video-editor';
import { MyComposition } from './MyComposition'; // your Remotion composition (React)
// Passed straight from the preview snippet: <RemotionPreview {ctx} />
let { ctx }: { ctx: SectionCtx } = $props();
const editor = ctx.editor;
let mountEl: HTMLDivElement;
let root: Root;
let player: PlayerRef | null = null;
// Suppress the echo when an editor→player seek triggers the player's own event.
let applying = false;
// aspectRatio has no width/height — derive output pixels (16:9 → 1920×1080).
const dims = $derived(compositionSize(editor.project.aspectRatio));
onMount(() => {
root = createRoot(mountEl);
renderPlayer();
});
onDestroy(() => root?.unmount());
function renderPlayer() {
root.render(
createElement(Player, {
ref: (r: PlayerRef | null) => (player = r),
component: MyComposition,
// Map clips/tracks + resolveAsset into your composition's props:
inputProps: { project: editor.project, resolveAsset: ctx.host.resolveAsset },
durationInFrames: Math.max(1, secToFrame(editor.duration, editor.fps)),
compositionWidth: dims.width,
compositionHeight: dims.height,
fps: editor.fps,
controls: false, // the editor's TransportBar already drives playback
style: { width: '100%', height: '100%' }
})
);
}
// Re-render the Player when the timeline/aspect changes.
$effect(() => {
void editor.project; // re-run on project edits
if (root) renderPlayer();
});
// editor → player: keep the Player's frame + play state in sync with the store.
$effect(() => {
if (!player) return;
const frame = secToFrame(editor.playhead, editor.fps);
if (player.getCurrentFrame() !== frame) {
applying = true;
player.seekTo(frame);
applying = false;
}
if (editor.playing && player.isPlaying() === false) player.play();
if (!editor.playing && player.isPlaying()) player.pause();
});
// player → editor: when the user scrubs/plays inside the Player, push it back.
$effect(() => {
if (!player) return;
const onSeek = (e: { detail: { frame: number } }) => {
if (applying) return; // ignore our own seekTo
editor.seekFrame(e.detail.frame);
};
const onPlay = () => editor.play();
const onPause = () => editor.pause();
player.addEventListener('seeked', onSeek);
player.addEventListener('play', onPlay);
player.addEventListener('pause', onPause);
return () => {
player?.removeEventListener('seeked', onSeek);
player?.removeEventListener('play', onPlay);
player?.removeEventListener('pause', onPause);
};
});
</script>
<div bind:this={mountEl} class="h-full w-full"></div>Then drop it into the preview snippet:
<TimelineEditor {project} {onChange} {resolveAsset} {generateThumbnail} {onExport} {can}>
{#snippet preview(ctx)}
<RemotionPreview {ctx} />
{/snippet}
</TimelineEditor>The
applyingguard matters. Without it, an editor→playerseekTofires the Player'sseekedevent, which callseditor.seekFrame, which re-runs the editor→player effect — an infinite ping-pong. Gate one direction (here, the player→editor push ignores seeks we initiated).
Inside MyComposition (your React code), import from @ariefsn/svelte-video-editor/pure and let
the helpers decide layering, timing, styles and audio — the result matches the editor preview:
import { AbsoluteFill, Audio, Img, OffthreadVideo, Sequence, useCurrentFrame } from 'remotion';
import {
backgroundCss,
clipAnimStyle,
clipAudible,
clipGain,
compositionSize,
isMediaClip,
layeredClips,
mediaClipCss,
textClipCss,
type TimelineProject
} from '@ariefsn/svelte-video-editor/pure';
export function MyComposition({
project,
urls
}: {
project: TimelineProject;
urls: Record<string, string>;
}) {
const frame = useCurrentFrame();
const { fps } = project;
const t = frame / fps;
const { height } = compositionSize(project.aspectRatio);
return (
<AbsoluteFill style={{ background: backgroundCss(project.background) }}>
{layeredClips(project).map(({ clip, zIndex }) => {
const anim = clipAnimStyle(clip, t, fps);
return (
<Sequence key={clip.id} from={clip.startF} durationInFrames={clip.durationF}>
{!isMediaClip(clip) ? (
<div style={{ zIndex, ...textClipCss(clip.style, height, anim) }}>{clip.text}</div>
) : clip.kind === 'image' ? (
<Img src={urls[clip.id]} style={{ zIndex, ...mediaClipCss(anim) }} />
) : (
(() => {
const Media = clip.kind === 'video' ? OffthreadVideo : Audio;
const audible = clipAudible(project, clip);
return (
<Media
src={urls[clip.id]}
startFrom={clip.trimInF} // `trimBefore` on newer Remotion
// f is relative to the Sequence start
volume={(f) => (audible ? clipGain(clip, (clip.startF + f) / fps, fps) : 0)}
style={{ zIndex, ...mediaClipCss(anim) }}
/>
);
})()
)}
</Sequence>
);
})}
</AbsoluteFill>
);
}Resolve media URLs (resolveAsset(clip.assetId), falling back to clip.url) before rendering
and pass them in via inputProps.
Simpler path: if you only want Remotion for high-fidelity output (not live scrubbing), skip the
previewsnippet entirely and keep the built-inPreviewStage. Just feedprojectinto a Remotion render insideonExport(project, range?)— no React island, no sync glue.
There is no locale prop — pass a fully-translated messages map. messages is fully typed, so
you get autocomplete for every key and a compile error on typos:
import type { Messages } from '@ariefsn/svelte-video-editor';
import { defaultMessages } from '@ariefsn/svelte-video-editor';
// Partial override — every key autocompletes:
const messages: Partial<Messages> = {
export: 'Render',
split: 'Cut',
// most keys are strings; a few interpolate and are functions:
delete_clips_confirm: ({ count }) => `Delete ${count} clip(s)?`
};<!-- `messages` is typed as Partial<Messages> -> IntelliSense lists all keys -->
<TimelineEditor messages={{ export: 'Render', split: 'Cut' }} … />Exports for typing/translating:
Messages— the full message map type (all keys; strings + the 4 interpolating functions).MessagesOverride=Partial<Messages>— the type of themessagesprop.MessageKey— a union of every key name.defaultMessages— the English defaults (clone it as a base for a full translation).
Swap the whole messages object at runtime to switch languages reactively.
MIT © Arief Setiyo Nugroho — me@ariefsn.dev
See LICENSE.