@asky-ai2/vue-timeline-editor
v0.0.6
Published
Interactive timeline editor for Vue 3 (drag, resize, zoom)
Downloads
407
Readme
Vue Timeline Editor
A lightweight, flexible, and interactive timeline editor for Vue 3.
Designed for building booking systems, schedulers, and resource timelines with full control over UI and behavior. Every interactive capability is an opt-in, tree-shakeable feature component — you only ship what you mount.
✨ Features
- 📅 Horizontal, time-based grid with configurable range and zoom
- 🧱 Multi-section / multi-row layout (tables, resources, zones)
- 🖱 Drag frames to move them between times and rows (
<Dnd/>) - ↔️ Resize frames from either edge (
<Resize/>) - 🎯 Snap to grid, frame edges, and rows (
<Snapping/>) - 📏 Static + active snap guide lines (
<SnapGuideLines/>) - ➕ Click (or tap) an empty area to create a frame (
<Sections rowClickable/>) - 🔗 Linked frames that move/resize together (
<JoinRows/>) - ▶️ Playhead with drag, click-seek, and a built-in play loop (
<Playhead/>) - ✋ Pan by click-drag (
<PanScroll/>) - 🚫 Blocked time ranges on rows and sections (
<BlockedAreas/>) - 🔍 Section filtering — show only specific sections at runtime
- 🪝 Optimistic add/remove with server-confirm and auto-revert
- 🎨 Fully customizable via slots and a single overridable theme stylesheet
- ⚡ Sticky section headers, transform-based axis rendering
🚀 Installation
npm install @asky-ai2/vue-timeline-editorPeer dependency: Vue ^3.3.
Import the base theme once in your app entry:
import '@asky-ai2/vue-timeline-editor/basic-theme.css';⚡ Quick start
<script setup lang="ts">
import {
Timeline, Sections, Dnd, Resize, Snapping, SnapGuideLines,
} from '@asky-ai2/vue-timeline-editor';
import '@asky-ai2/vue-timeline-editor/basic-theme.css';
const sections = [
{
title: 'Section 1',
uuid: 'section1',
rows: [
{
uuid: 'row1',
title: 'Row 1',
frames: [
{ uuid: 'frame1', title: 'Booking', start_ms: 0, end_ms: 2 * 60 * 60 * 1000 },
],
},
],
},
];
</script>
<template>
<Timeline :initial-range="{ start_seconds: 0, end_seconds: 24 * 60 * 60 }">
<Sections :sections="sections" />
<Dnd />
<Resize />
<Snapping />
<SnapGuideLines />
</Timeline>
</template><Timeline> is the only required component. Everything inside it is optional —
mount a feature component to enable that behavior, omit it to drop the code.
🧩 Components
<Timeline>
Root component. Provides the timeline context to every descendant feature.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| initialRange | { start_seconds, end_seconds } | 0 → 24h | Visible time window. |
| timeAxisTimeFormat | string | 'HH:mm:ss' | Time-axis label format (e.g. 'hh:mm a'). |
| Event | Payload | Description |
|-------|---------|-------------|
| @init | TimelineInitInterface | Fires once mounted — exposes the imperative API. |
| @scroll | Event | Editor scrolled. |
| Slot | Props | Description |
|------|-------|-------------|
| #frame | { uuid, title, startMs, endMs, meta, selected, ... } | Replaces a frame's inner content. |
| #rowLabel | { uuid, title, sectionUuid } | Replaces a row's label in the row axis. |
Reactive slot content. Slot templates render in your component's scope, so they can close over your own reactive state:
<script setup>
const rowAccents = ref({});
</script>
<template>
<Timeline>
<template #rowLabel="{ uuid, title }">
<span :style="{ color: rowAccents[uuid] }">{{ title }}</span>
</template>
</Timeline>
</template><Sections>
Initializes section/row/frame data and provides click-to-add on empty areas.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| sections | TimelineSectionInterface[] | — | The section/row/frame tree. |
| filteredSections | (string \| number)[] \| null | null | Show only the listed section UUIDs. null shows all. Changes apply instantly without re-initializing frame data. |
| rowClickable | boolean | false | Enable the empty-area new-frame suggestion globally. |
| newFrameLabel | string | '+ Add' | Touch-mode label text. |
| availableSlots | { uuid, available_slots: { start_ms, end_ms }[] }[] | — | Allowlist: dims/locks the editor except these slots. Clears to exit mode. |
| selectedRowUuids | (string \| number)[] | — | Row UUIDs whose allowed slots render selected. |
| frameHoldDuration | number | 600 | Press-and-hold time (ms) before @frame-hold fires. |
| frameHoldThreshold | number | 8 | Movement tolerance (px) that cancels the hold. |
| silentExternalSelection | boolean | false | When true, @frame-selected / @frame-deselected only fire for user-driven clicks. |
| Event | Payload | Description |
|-------|---------|-------------|
| @add-frame | { rowUuid, sectionUuid, start_ms, end_ms } | Empty area activated. |
| @select-slot | { rowUuid, sectionUuid, start_ms, end_ms } | An allowlist slot was clicked. |
| @frame-hold | (frame, event) | A frame was pressed and held. |
| @frame-selected | (frame) | A frame became the primary selection. |
| @frame-deselected | (frame) | A frame was deselected (always before @frame-selected). |
| Slot | Props | Description |
|------|-------|-------------|
| #label | { rowUuid, sectionUuid, start_ms, end_ms, length_ms } | Overrides the touch-mode suggestion label. |
Section filtering — filteredSections lets you show only a subset of sections without discarding frame data. The editor height, grid, snapping, and row suggestions all recalculate as if only the visible sections exist. Restoring null brings everything back instantly.
<Sections :sections="sections" :filtered-sections="['floor-1', 'floor-2']" /><Dnd>
Drag frames to move them. Drags the whole selection together.
- Prop:
edgeSnap(boolean, defaulttrue). - Events:
@dragStart,@dragEnd,@drop,@dragCancel,@dragBlocked. - Slot:
#highlighter— replaces the drop-target indicator.
<Resize>
Resize a frame from either edge.
- Events:
@resizeStart,@resizeEnd,@resized,@resizeCancel,@resizeBlocked.
<Snapping>
Snaps drag/resize to the grid, frame edges, and rows, and protects against overlap. Each pipeline step is a toggle prop (all default true):
rows, frames, times, guides, overlapping.
<Snapping :overlapping="false" /> <!-- allow overlapping frames --><SnapGuideLines>
Renders grid lines and active snap guides.
- Props:
majorGrid(true),minorGrid(false),activeSnapGuides(true). - Slots:
#majorGrid,#minorGrid,#activeSnapGuide.
<JoinRows>
Frames sharing a linkGroupUuid move and resize as one linked group.
No props — mount alongside <Dnd/> / <Resize/>.
<PanScroll>
Click-and-drag panning (mouse only; touch scrolls natively). No props.
<Playhead>
A draggable position marker with a ruler handle, a full-height line, and an off-screen edge indicator.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| v-model | number | — | Playhead position in ms (two-way). |
| v-model:playing | boolean | — | Playback running state (two-way). |
| rate | number | 1 | Timeline-ms advanced per real-ms while playing. |
| loop | boolean | false | Wrap to range start instead of pausing at end. |
| updateInterval | number | 0 | Throttle reactive position updates. 0 = every frame, 1000 = once per second. The internal position still advances every frame; only the reactive v-model write is throttled. |
| draggable | boolean | true | Allow dragging / ruler scrubbing. |
| clickSeek | boolean | true | Allow clicking the ruler to jump. |
| onTimelineMountedScrollToPlayHead | boolean | false | Scroll the playhead into view once after mount. |
| Event | Description |
|-------|-------------|
| @seek | Fired on seek (drag end or click). |
| @dragStart / @dragEnd | Drag lifecycle. |
#handle slot — the slot receives secondsToTimeString as a prop so you can display the current time in the handle without importing a utility:
<Playhead v-model="posMs">
<template #handle="{ currentMs, secondsToTimeString }">
<div class="my-handle">
{{ secondsToTimeString(currentMs / 1000) }}
</div>
</template>
</Playhead>Out of range, the playhead hides and an edge arrow points toward it.
<BlockedAreas>
Renders visual overlays for blocked time windows on individual rows or entire sections. Blocked areas are purely visual — they do not prevent drag/resize by default.
<BlockedAreas
:row-blockages="rowBlockages"
:section-blockages="sectionBlockages"
/>| Prop | Type | Description |
|------|------|-------------|
| rowBlockages | RowBlockage[] \| null | Row-level blockages. |
| sectionBlockages | SectionBlockage[] \| null | Section-level blockages (covers all rows). |
interface RowBlockage {
rowUuids: (string | number)[]; // rows to block — any order
start_ms?: number | null; // null / omit = full timeline width
end_ms?: number | null;
full_row?: boolean; // explicit "all day" intent flag
block_ulid: string; // stable id for grouping / click handlers
title?: string | null;
}
interface SectionBlockage {
sectionUuid: string | number;
start_ms?: number | null;
end_ms?: number | null;
full_row?: boolean;
block_ulid: string;
title?: string | null;
}Sequential row merging — consecutive rowUuids in a single RowBlockage are merged into one visual block. Non-consecutive rows produce separate blocks. A single blockage with rows [1, 2, 3, 5] produces two blocks: one spanning rows 1–3, one for row 5.
Section vs row appearance — section blockages have no top/bottom border in the default theme (they cover all rows). The #blockage slot receives isSection so you can differentiate styling.
#blockage slot — a single slot applied to every rendered block:
<BlockedAreas :row-blockages="blockages">
<template #blockage="{ title, isSection, rowUuids, block_ulid, blockage }">
<div class="my-block" @click="openSidebar(block_ulid)">
{{ title }}
</div>
</template>
</BlockedAreas>Slot props: key, top, left, width, height, isSection, rowUuids, title, block_ulid, full_row, blockage (original object).
🗂 Data shapes
interface TimelineSectionInterface {
uuid: string | number;
title: string;
rows: TimelineRowInterface[];
}
interface TimelineRowInterface {
uuid: string | number;
title: string;
frames: TimelineFrameInterface[];
new_frame_ms?: number; // optional click-to-add length hint
}
interface TimelineFrameInterface {
uuid: string | number;
title: string | null;
start_ms: number; // absolute ms
end_ms: number;
linkGroupUuid?: string | number; // links frames for <JoinRows/>
meta?: unknown; // arbitrary payload, passed to #frame slot
}All times are absolute milliseconds. The visible window is set in seconds via initialRange.
🔌 Imperative API
@init hands back the live API:
import type { TimelineInitInterface } from '@asky-ai2/vue-timeline-editor';
const onInit = (api: TimelineInitInterface) => {
// api.timeline — data + frame CRUD
// api.config — reactive config (range, zoom, pixel-per-ms, …)
// api.features — mounted feature instances
// Frame CRUD
const handle = api.timeline.addFrame(
{ uuid: 'tmp', title: 'New', start_ms: 0, end_ms: 3600000, rowUuid: 'row1', sectionUuid: 'section1' },
async (frame) => ({ uuid: await saveToServer(frame) }), // optional server sync
);
handle.promise
.then(() => console.log('saved as', handle.uuid))
.catch(() => console.log('reverted'));
api.timeline.removeFrame('frame1', () => deleteOnServer('frame1'));
api.timeline.updateFrame('frame1', { ...patch });
api.timeline.syncFrame({ uuid: 'frame1', ...data }); // upsert
api.timeline.syncFrames([...frames]); // bulk upsert
// Section data (also callable async, after @init)
api.timeline.initSections(sections);
api.timeline.setSectionFilter(['floor-1']); // reactive filter
// Navigation
api.timeline.scrollToViewPort('frame1', /* highlight */ true);
// Pending / loading state
api.timeline.setPending('frame1', true);
api.timeline.setLoadingMode('immediate');
// Selection (requires <Sections/>)
api.features.data.frames.getSelectedFrames();
// Playhead (requires <Playhead/>)
api.features.data.playhead?.seek(3600000);
api.features.data.playhead?.play();
api.features.data.playhead?.pause();
};addFrame / removeFrame are optimistic — the change applies immediately; if the sync/confirm callback rejects, it auto-reverts. No boilerplate rollback code needed.
🎨 Theming
All visuals come from basic-theme.css. Override any .vtd__* class in your own CSS to restyle. Structural rules are injected automatically from the package bundle; colors, borders, and typography live in basic-theme.css, which you import and may override freely.
🌳 Tree-shaking
Feature components are also published as individual entry points:
import { Timeline } from '@asky-ai2/vue-timeline-editor';
import Dnd from '@asky-ai2/vue-timeline-editor/features/dnd';
import Snapping from '@asky-ai2/vue-timeline-editor/features/snapping';
import Sections from '@asky-ai2/vue-timeline-editor/features/sections';
import Resize from '@asky-ai2/vue-timeline-editor/features/resize';
import Playhead from '@asky-ai2/vue-timeline-editor/features/playhead';👤 Author
Mohammudullah Khan — [email protected]
📄 License
MIT
