Skip to content

About

A host-agnostic Svelte 5 video timeline editor — clips, tracks, trimming, ripple/roll/slip edits, grouping, linked audio, markers, undo/redo and a real-time preview. Zero design-system dependency.

Topics

Resources

Stars

30 stars

Watchers

0 watching

Forks

Repository files navigation

svelte-video-editor

@ariefsn/svelte-video-editor

npm version npm downloads minzipped size svelte 5 types included license MIT

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 animation rides 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)).

Contents

Install

bun add @ariefsn/svelte-video-editor
# or: npm i @ariefsn/svelte-video-editor
# or: pnpm add @ariefsn/svelte-video-editor

Requires svelte@^5 and Tailwind v4 in your app.

Theme

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 */
}

Simple mode

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.


Advanced mode

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). Run bun run dev.


Opening media

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.

A local-file picker (click to browse + drop files)

<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 the assetId and resolve a fresh URL each session — strip blob: values in your onChange handler 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 (SSR)

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.)


Host contract

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).

Clip transitions

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.

Section overrides (snippets)

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.

Custom preview renderer

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.

What the preview snippet gives you

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('');
}

Using Remotion specifically

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:

  1. 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.
  2. Playback. The built-in PlaybackEngine only drives the native <video>/<audio> elements in PreviewStage — it does not drive Remotion. You sync the editor store and the Player's playerRef to 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 —

Bridge component (RemotionPreview.svelte)

<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 applying guard matters. Without it, an editor→player seekTo fires the Player's seeked event, which calls editor.seekFrame, which re-runs the editor→player effect — an infinite ping-pong. Gate one direction (here, the player→editor push ignores seeks we initiated).

Mapping the timeline into a composition

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 preview snippet entirely and keep the built-in PreviewStage. Just feed project into a Remotion render inside onExport(project, range?) — no React island, no sync glue.

Localization

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 the messages prop.
  • 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.


License

MIT © Arief Setiyo Nugroho — me@ariefsn.dev

See LICENSE.

About

A host-agnostic Svelte 5 video timeline editor — clips, tracks, trimming, ripple/roll/slip edits, grouping, linked audio, markers, undo/redo and a real-time preview. Zero design-system dependency.

Topics

Resources

Stars

30 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages