Component · Data display
Changelog Timeline
Vertical changelog timeline for Vue 3 with version badges, dated releases, typed change tags and filter chips. Built with Tailwind CSS, dark mode.
A flexible board component built for smooth panning, zooming with a grid layout. It supports both mouse and touch input, making it a great fit for whiteboards, diagram tools, mind maps, and workflow editors. Designed for performance and responsiveness, it delivers a fluid experience on desktop and mobile with intuitive gestures and real-time interaction on a scalable canvas.
v1.0.0Updated last year
<template>
<div
v-show="!loading"
class="board"
@mousedown="startDrag"
@wheel.prevent="onWheel"
@touchstart="onTouchStart"
@touchmove.prevent="onTouchMove"
@touchend="onTouchEnd"
@click.prevent.stop=""
@dblclick.prevent.stop="resetView"
ref="boardRef"
:style="boardStyle"
>
<svg
:width="width"
:height="height"
class="board-svg"
>
<g>
<line
v-for="xLine in verticalLines"
:key="'v'+xLine"
:x1="xLine"
y1="0"
:x2="xLine"
:y2="height"
:stroke="resolvedGridColor"
stroke-width="1"
/>
<line
v-for="yLine in horizontalLines"
:key="'h'+yLine"
x1="0"
:y1="yLine"
:x2="width"
:y2="yLine"
:stroke="resolvedGridColor"
stroke-width="1"
/>
</g>
</svg>
<div
class="board-content"
:style="contentStyle"
>
<slot>
<div style="user-select: none; transform:translateX(-50%) translateY(-50%)">Center</div>
</slot>
</div>
</div>
</template>
<script>
export default {
name: 'Board',
inject: {
theme: {
default: 'light'
}
},
props: {
zoom: {
type: Number,
default: 1,
control: 'slider',
min: 0,
max: 1
},
x: {
type: Number,
default: 0,
control: 'slider',
min: -1000,
max: 1000
},
y: {
type: Number,
default: 0,
control: 'slider',
min: -1000,
max: 1000
},
width: {
type: [Number, String],
default: '100%',
control: 'slider',
unit: '%',
min: 0,
max: 100
},
height: {
type: [Number, String],
default: '100%',
control: 'slider',
unit: '%',
min: 0,
max: 100
},
backgroundColorLight: {
type: String,
default: '#f9f9f9',
control: 'color'
},
gridColorLight: {
type: String,
default: '#e5e5e5',
control: 'color'
},
colorLight: {
type: String,
default: '#242C33',
control: 'color'
},
backgroundColorDark: {
type: String,
default: '#242C33',
control: 'color'
},
gridColorDark: {
type: String,
default: '#070A0D',
control: 'color'
},
colorDark: {
type: String,
default: '#f9f9f9',
control: 'color'
}
},
data() {
return {
scale: this.zoom,
offsetX: this.x,
offsetY: this.y,
dragging: false,
startX: 0,
startY: 0,
lastOffsetX: this.x,
lastOffsetY: this.y,
gridSize: 200,
touchDistance: 0,
touchCenter: {
x: 0,
y: 0
},
defaultZoom: this.zoom,
defaultX: this.x,
defaultY: this.y,
currentWidth: this.width,
currentHeight: this.height,
loading: true
};
},
computed: {
resolvedBackground() {
return this.theme === 'dark' ? this.backgroundColorDark : this.backgroundColorLight;
},
resolvedColor() {
return this.theme === 'dark' ? this.colorDark : this.colorLight;
},
resolvedGridColor() {
return this.theme === 'dark' ? this.gridColorDark : this.gridColorLight;
},
boardStyle() {
return {
backgroundColor: this.resolvedBackground,
color: this.resolvedColor
};
},
verticalLines() {
const lines = [];
const step = this.gridSize * this.scale;
if (step < 1) return lines;
const leftWorldX = -this.offsetX / this.scale;
const rightWorldX = (this.currentWidth - this.offsetX) / this.scale;
const startLine = Math.floor(leftWorldX / this.gridSize);
const endLine = Math.ceil(rightWorldX / this.gridSize);
for (let i = startLine; i <= endLine; i++) {
const x = i * this.gridSize * this.scale + this.offsetX;
if (x >= 0 && x <= this.currentWidth) {
lines.push(x);
}
}
return lines;
},
horizontalLines() {
const lines = [];
const step = this.gridSize * this.scale;
if (step < 1) return lines;
const topWorldY = -this.offsetY / this.scale;
const bottomWorldY = (this.currentHeight - this.offsetY) / this.scale;
const startLine = Math.floor(topWorldY / this.gridSize);
const endLine = Math.ceil(bottomWorldY / this.gridSize);
for (let i = startLine; i <= endLine; i++) {
const y = i * this.gridSize * this.scale + this.offsetY;
if (y >= 0 && y <= this.currentHeight) {
lines.push(y);
}
}
return lines;
},
contentStyle() {
return {
transform: `translate(${this.offsetX}px, ${this.offsetY}px) scale(${this.scale})`,
transformOrigin: '0 0',
position: 'absolute'
};
}
},
watch: {
zoom(val) {
this.scale = val;
},
x(val) {
this.offsetX = this.currentWidth / 2 + val;
},
y(val) {
this.offsetY = this.currentHeight / 2 + val;
},
offsetX(val) {
if (this.loading) return;
localStorage.setItem('pan-x', val);
},
offsetY(val) {
if (this.loading) return;
localStorage.setItem('pan-y', val);
},
scale(val) {
if (this.loading) return;
localStorage.setItem('scale', val);
}
},
mounted() {
window.addEventListener('mousemove', this.onDrag);
window.addEventListener('mouseup', this.stopDrag);
window.addEventListener('resize', this.onResize);
this.onResize();
this.scale = this.scale;
this.offsetX = this.currentWidth / 2 + this.x;
this.offsetY = this.currentHeight / 2 + this.y;
this.$nextTick(() => {
const x = localStorage.getItem('pan-x');
const y = localStorage.getItem('pan-y');
const scale = localStorage.getItem('scale');
if (x) this.offsetX = Number(x);
if (y) this.offsetY = Number(y);
if (scale) this.scale = Number(scale);
this.$emit('update:zoom', this.scale);
this.$emit('update:x', this.offsetX);
this.$emit('update:y', this.offsetY);
this.loading = false;
});
},
beforeUnmount() {
window.removeEventListener('mousemove', this.onDrag);
window.removeEventListener('mouseup', this.stopDrag);
window.removeEventListener('resize', this.onResize);
},
methods: {
startDrag(e) {
this.dragging = true;
this.startX = e.clientX;
this.startY = e.clientY;
this.lastOffsetX = this.offsetX;
this.lastOffsetY = this.offsetY;
},
onDrag(e) {
if (!this.dragging) return;
const dx = e.clientX - this.startX;
const dy = e.clientY - this.startY;
this.offsetX = this.lastOffsetX + dx;
this.offsetY = this.lastOffsetY + dy;
this.$emit('update:x', this.offsetX);
this.$emit('update:y', this.offsetY);
},
stopDrag() {
this.dragging = false;
},
onWheel(e) {
const mouseX = e.clientX;
const mouseY = e.clientY;
const worldX = (mouseX - this.offsetX) / this.scale;
const worldY = (mouseY - this.offsetY) / this.scale;
const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9;
this.scale *= zoomFactor;
if (this.scale < 0.1) this.scale = 0.1;
this.offsetX = mouseX - worldX * this.scale;
this.offsetY = mouseY - worldY * this.scale;
this.$emit('update:zoom', this.scale);
this.$emit('update:x', this.offsetX);
this.$emit('update:y', this.offsetY);
},
onTouchStart(e) {
if (e.touches.length === 2) {
this.touchDistance = this.getDistance(e.touches);
this.touchCenter = this.getCenter(e.touches);
} else if (e.touches.length === 1) {
this.dragging = true;
this.startX = e.touches[0].clientX;
this.startY = e.touches[0].clientY;
this.lastOffsetX = this.offsetX;
this.lastOffsetY = this.offsetY;
}
},
onTouchMove(e) {
if (e.touches.length === 2) {
const newDistance = this.getDistance(e.touches);
const scaleFactor = newDistance / this.touchDistance;
const newScale = this.scale * scaleFactor;
if (newScale > 0.1) {
const worldX = (this.touchCenter.x - this.offsetX) / this.scale;
const worldY = (this.touchCenter.y - this.offsetY) / this.scale;
this.scale = newScale;
this.offsetX = this.touchCenter.x - worldX * this.scale;
this.offsetY = this.touchCenter.y - worldY * this.scale;
this.touchDistance = newDistance;
this.$emit('update:zoom', this.scale);
this.$emit('update:x', this.offsetX);
this.$emit('update:y', this.offsetY);
}
} else if (this.dragging && e.touches.length === 1) {
const dx = e.touches[0].clientX - this.startX;
const dy = e.touches[0].clientY - this.startY;
this.offsetX = this.lastOffsetX + dx;
this.offsetY = this.lastOffsetY + dy;
this.$emit('update:x', this.offsetX);
this.$emit('update:y', this.offsetY);
}
},
onTouchEnd() {
this.dragging = false;
},
getDistance(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
},
getCenter(touches) {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2
};
},
centerOn(x, y) {
const centerX = this.currentWidth / 2;
const centerY = this.currentHeight / 2;
this.offsetX = centerX - x * this.scale;
this.offsetY = centerY - y * this.scale;
this.$emit('update:x', this.offsetX);
this.$emit('update:y', this.offsetY);
},
resetView() {
this.scale = this.defaultZoom;
this.offsetX = this.currentWidth / 2 + this.x;
this.offsetY = this.currentHeight / 2 + this.y;
this.$emit('update:zoom', this.scale);
this.$emit('update:x', this.offsetX);
this.$emit('update:y', this.offsetY);
},
onResize() {
const centerWorldX = (this.currentWidth / 2 - this.offsetX) / this.scale;
const centerWorldY = (this.currentHeight / 2 - this.offsetY) / this.scale;
this.currentWidth = window.innerWidth;
this.currentHeight = window.innerHeight;
this.offsetX = this.currentWidth / 2 - centerWorldX * this.scale;
this.offsetY = this.currentHeight / 2 - centerWorldY * this.scale;
this.$emit('update:x', this.offsetX);
this.$emit('update:y', this.offsetY);
}
}
};
</script>
<style scoped>
.board {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
cursor: grab;
transition: all 0.1s linear;
}
.board:active {
cursor: grabbing;
}
.board-svg {
display: block;
position: absolute;
top: 0;
left: 0;
}
.board-content {
top: 0;
left: 0;
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
| zoom | Number | 1 | — |
| x | Number | 0 | — |
| y | Number | 0 | — |
| width | Number | String | '100%' | — |
| height | Number | String | '100%' | — |
| backgroundColorLight | String | '#f9f9f9' | — |
| gridColorLight | String | '#e5e5e5' | — |
| colorLight | String | '#242C33' | — |
| backgroundColorDark | String | '#242C33' | — |
| gridColorDark | String | '#070A0D' | — |
| colorDark | String | '#f9f9f9' | — |
Component · Data display
Vertical changelog timeline for Vue 3 with version badges, dated releases, typed change tags and filter chips. Built with Tailwind CSS, dark mode.
Component · Data display
Vue 3 kanban board with native drag and drop, keyboard moves, WIP limits and inline add card. v-model board state, Tailwind CSS, dark mode.
Component · Data display
Vue 3 KPI stat cards with trend deltas and tiny inline SVG sparklines, no chart library needed. Tailwind CSS, dark mode, typed props.
Component · Data display
Vue 3 team section with a responsive grid of member cards: initials avatars, role, short bio, location and social links. Tailwind CSS, dark mode.